From 6636749ad72fe558983b54ed2e1028ee23da690a Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 07:35:04 +0100 Subject: [PATCH 1/9] Add mock project for os-agnostic CLI invocations --- .../Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs | 108 ++++++++++++++++++ .../Oma.WndwCtrl.Core.Mocks.csproj | 15 +++ Directory.Packages.props | 1 + .../component-configuration-windows.json | 19 ++- .../Executors/Commands/CliCommandExecutor.cs | 57 +++++---- .../MgmtApiService.config.json | 2 +- Oma.WndwCtrl.sln | 7 ++ 7 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 .unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs create mode 100644 .unitTests/Oma.WndwCtrl.Core.Mocks/Oma.WndwCtrl.Core.Mocks.csproj diff --git a/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs b/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs new file mode 100644 index 0000000..ebcd83b --- /dev/null +++ b/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs @@ -0,0 +1,108 @@ +using System.Text.RegularExpressions; +using CommandLine; +using CommandLine.Text; +using JetBrains.Annotations; + +namespace Oma.WndwCtrl.Core.Mocks; + +public static partial class StringExtensions +{ + [GeneratedRegex(@"\r\n|\n|\\r\\n|\\n")] + private static partial Regex NewLineRegex(); + + public static IEnumerable SplitToLines(this string input) + { + Regex split = NewLineRegex(); + string[] res = split.Split(input); + return res; + } +} + +[PublicAPI] +public class CliCommandMock +{ + public static int Main(string[] args) + { + using Parser parser = new( + with => + { + with.CaseSensitive = false; + with.CaseInsensitiveEnumValues = true; + with.EnableDashDash = true; + } + ); + + ParserResult? parserResult = + parser.ParseArguments(args); + + if (parserResult.Tag != ParserResultType.NotParsed) + { + return RunOptions(parserResult.Value); + } + + string helpText = HelpText.AutoBuild(parserResult).ToString(); + Console.Error.WriteLine("Incorrect arguments provided. Please refer to the following help text:"); + Console.WriteLine(helpText); + + return 255; // Reserved exit code to indicate something is wrong with the invocation. + } + + private static int RunOptions(CliInvocationOptions opts) + { + if (opts.ExitCode >= 255) + { + Console.Error.WriteLine("Exit code must be less than 255. This is not a valid test."); + return 255; + } + + List> writers = []; + + if (opts.TargetStreams.HasFlag(CliInvocationOptions.OutputTarget.StdOut)) + { + writers.Add(Console.WriteLine); + } + + if (opts.TargetStreams.HasFlag(CliInvocationOptions.OutputTarget.StdErr)) + { + writers.Add(Console.Error.WriteLine); + } + + WriteText(opts.Text, writers); + + return opts.ExitCode; + } + + private static void WriteText(string text, List> writers) + { + foreach (string line in text.SplitToLines()) + foreach (Action writer in writers) + writer(line); + } +} + +[PublicAPI] +public record CliInvocationOptions +{ + [Flags] + public enum OutputTarget + { + StdOut = 1, + StdErr = 2, + } + + [Option(shortName: 'c', "code", Required = false, HelpText = "The exit code to return.")] + public int ExitCode { get; init; } + + [Option( + shortName: 's', + "targetStreams", + Required = false, + HelpText = "If true, writes provided text to standard error instead of out." + )] + public OutputTarget TargetStreams { get; init; } = OutputTarget.StdOut; + + [Option(shortName: 't', "text", Required = true, HelpText = "The text to write to standard out or error.")] + public string Text { get; init; } = string.Empty; + + public override string ToString() => $"--code {ExitCode} --targetStreams {TargetStreams} --text \"{Text}\""; +} \ No newline at end of file diff --git a/.unitTests/Oma.WndwCtrl.Core.Mocks/Oma.WndwCtrl.Core.Mocks.csproj b/.unitTests/Oma.WndwCtrl.Core.Mocks/Oma.WndwCtrl.Core.Mocks.csproj new file mode 100644 index 0000000..0b9bfa2 --- /dev/null +++ b/.unitTests/Oma.WndwCtrl.Core.Mocks/Oma.WndwCtrl.Core.Mocks.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + Exe + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index d77ee3c..e058358 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/Oma.WndwCtrl.Configuration/component-configuration-windows.json b/Oma.WndwCtrl.Configuration/component-configuration-windows.json index c7170f9..2312cef 100644 --- a/Oma.WndwCtrl.Configuration/component-configuration-windows.json +++ b/Oma.WndwCtrl.Configuration/component-configuration-windows.json @@ -39,7 +39,7 @@ }, "acaad.usage.ram": { "type": "sensor", - "active": false, + "active": true, "queryCommand": { "type": "cli", "fileName": "tasklist.exe", @@ -65,7 +65,7 @@ }, "ping-google": { "type": "sensor", - "active": false, + "active": true, "queryCommand": { "type": "cli", "fileName": "ping", @@ -92,7 +92,7 @@ }, "ip-addr": { "type": "sensor", - "active": false, + "active": true, "queryCommand": { "type": "cli", "fileName": "ipconfig", @@ -128,7 +128,7 @@ }, "test-switch": { "type": "switch", - "active": false, + "active": true, "queryCommand": { "type": "cli", "fileName": "C:\\Program Files\\Git\\usr\\bin\\bash.exe", @@ -157,7 +157,7 @@ }, "invalid-parameters": { "type": "button", - "active": false, + "active": true, "command": { "type": "cli", "fileName": "ping", @@ -170,6 +170,15 @@ "name": "RequestReceived-not-found" } ] + }, + "invalid-sc": { + "type": "button", + "command": { + "type": "cli", + "fileName": "sc.exe", + "arguments": "query2 not-exists", + "transformations": [] + } } } } \ No newline at end of file diff --git a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs index 5a1d989..9b2ca1d 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs @@ -1,11 +1,11 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text; -using CliWrap; using JetBrains.Annotations; using LanguageExt; using Oma.WndwCtrl.Abstractions; using Oma.WndwCtrl.Abstractions.Errors; using Oma.WndwCtrl.Abstractions.Model; +using Oma.WndwCtrl.Core.Errors.Commands; using Oma.WndwCtrl.Core.Model.Commands; using static LanguageExt.Prelude; @@ -27,45 +27,40 @@ public async Task> ExecuteAsync( { try { - StringBuilder stdOutBuffer = new(); - StringBuilder stdErrBuffer = new(); + ProcessStartInfo processStartInfo = new() + { + FileName = command.FileName, + Arguments = command.Arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; - Command cliBuilder = Cli.Wrap(command.FileName) - .WithArguments(command.Arguments ?? string.Empty) - .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer)) - .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) - .WithValidation(CommandResultValidation.None); + using Process? process = Process.Start(processStartInfo); - if (command.WorkingDirectory is not null) + if (process is null) { - cliBuilder = cliBuilder.WithWorkingDirectory(command.WorkingDirectory); + return Left( + new CliCommandError("Could not obtain process instance.", isExceptional: true, isExpected: false) + ); } - CommandResult result = await cliBuilder.ExecuteAsync(CancellationToken.None, cancelToken); + string errorText = string.Empty; + process.ErrorDataReceived += (sender, e) => { errorText += e.Data; }; - string stdOutRes = stdOutBuffer.ToString(); - string toUse; + process.BeginErrorReadLine(); + string allText = await process.StandardOutput.ReadToEndAsync(cancelToken); - if (result.ExitCode == 0) - { - toUse = stdOutRes; - } - else - { - string stdErrRes = stdErrBuffer.ToString(); + await process.WaitForExitAsync(cancelToken); - toUse = string.IsNullOrEmpty(stdErrRes) - ? stdOutRes - : stdErrRes; - } - - CommandOutcome outcome = new(toUse) - { - Success = result.ExitCode == 0, - }; + string outcome = process.ExitCode == 0 ? allText + : string.IsNullOrEmpty(errorText) ? allText : errorText; return Right( - outcome + new CommandOutcome(outcome) + { + Success = process.ExitCode == 0, + } ); } catch (OperationCanceledException ex) diff --git a/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json b/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json index 5be049e..efbbd36 100644 --- a/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json +++ b/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json @@ -9,7 +9,7 @@ }, "SchedulingService": { "CheckInterval": "00:00:00.001", - "Active": true + "Active": false }, "EventLoggingService": { "Enabled": false diff --git a/Oma.WndwCtrl.sln b/Oma.WndwCtrl.sln index 084c554..a425f9a 100644 --- a/Oma.WndwCtrl.sln +++ b/Oma.WndwCtrl.sln @@ -72,6 +72,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Windows", "Windows", "{4A56 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Ext.Windows.Media", "Oma.WndwCtrl.Extensions\Windows\Oma.WndwCtrl.Ext.Windows.Media\Oma.WndwCtrl.Ext.Windows.Media.csproj", "{418214AE-8597-4FAB-A511-C4194AD96E4F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Core.Mocks", ".unitTests\Oma.WndwCtrl.Core.Mocks\Oma.WndwCtrl.Core.Mocks.csproj", "{05B8421B-071F-4C9E-96DC-9978F0AE59F7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -150,6 +152,10 @@ Global {418214AE-8597-4FAB-A511-C4194AD96E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {418214AE-8597-4FAB-A511-C4194AD96E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {418214AE-8597-4FAB-A511-C4194AD96E4F}.Release|Any CPU.Build.0 = Release|Any CPU + {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {E7217C2E-5C78-4106-977D-3091CFA63F43} = {307F90ED-24AD-4725-960B-0A7AAFFE8C6D} @@ -164,5 +170,6 @@ Global {41D1E3C4-0AC3-4245-BB9C-68F8D13481ED} = {94B42F55-3A25-4022-B02B-28F4F945CB00} {4A56D8FD-7965-4FE2-B989-B72A3CB14FB6} = {9AE4447A-201F-4DCF-8715-803610028379} {418214AE-8597-4FAB-A511-C4194AD96E4F} = {4A56D8FD-7965-4FE2-B989-B72A3CB14FB6} + {05B8421B-071F-4C9E-96DC-9978F0AE59F7} = {307F90ED-24AD-4725-960B-0A7AAFFE8C6D} EndGlobalSection EndGlobal From e95b2114be6062aa533723ddab0e07a72eda5ad7 Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 08:51:42 +0100 Subject: [PATCH 2/9] Add Core IntTest project, implement itests for CliCommandExecutor. --- .../Commands/CliCommandExecutorTests.cs | 181 ++++++++++++++++++ .../Oma.WndwCtrl.Core.IntegrationTests.csproj | 27 +++ .../Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs | 3 +- Oma.WndwCtrl.Abstractions/Errors/FlowError.cs | 9 +- .../Executors/Commands/CliCommandExecutor.cs | 22 ++- Oma.WndwCtrl.sln | 7 + 6 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 .integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Executors/Commands/CliCommandExecutorTests.cs create mode 100644 .integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Oma.WndwCtrl.Core.IntegrationTests.csproj diff --git a/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Executors/Commands/CliCommandExecutorTests.cs b/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Executors/Commands/CliCommandExecutorTests.cs new file mode 100644 index 0000000..0a1a8e1 --- /dev/null +++ b/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Executors/Commands/CliCommandExecutorTests.cs @@ -0,0 +1,181 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using LanguageExt; +using Oma.WndwCtrl.Abstractions.Errors; +using Oma.WndwCtrl.Abstractions.Extensions; +using Oma.WndwCtrl.Abstractions.Model; +using Oma.WndwCtrl.Core.Executors.Commands; +using Oma.WndwCtrl.Core.Mocks; +using Oma.WndwCtrl.Core.Model.Commands; + +namespace Oma.WndwCtrl.Core.IntegrationTests.Executors.Commands; + +public sealed class CliCommandExecutorTests : IDisposable +{ + private const string DEFAULT_OUTPUT_TEXT = "Hello World!"; + + private readonly CliCommandExecutor _instance = new(); + private readonly CancellationToken _xunitCancelToken = TestContext.Current.CancellationToken; + + private Either? _result; + + public void Dispose() + { + _result?.Dispose(); + } + + [Fact] + public async Task SanityCheck() + { + CliCommand command = CreateCommand(); + + Func>> act = async () => + await _instance.ExecuteAsync(command, _xunitCancelToken); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ShouldSetSuccessTrueIfExitCodeIsZero() + { + CliCommand command = CreateCommand(); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeTrue() + ); + } + + [Fact] + public async Task ShouldSetSuccessFalseIfExitCodeIsNonZero() + { + CliCommand command = CreateCommand(exitCode: 1); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeFalse() + ); + } + + [Fact] + public async Task ShouldSetSuccessFalseIfStdErrIsWritten() + { + CliCommand command = CreateCommand(target: CliInvocationOptions.OutputTarget.StdErr); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeFalse() + ); + } + + [Fact] + public async Task ShouldReturnStdErrIfWrittenTo() + { + CliCommand command = CreateCommand(target: CliInvocationOptions.OutputTarget.StdErr); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.OutcomeRaw.Should().Be(DEFAULT_OUTPUT_TEXT) + ); + } + + [Fact] + public async Task ShouldHandleWritesToBothStreams() + { + CliCommand command = CreateCommand( + target: CliInvocationOptions.OutputTarget.StdOut | CliInvocationOptions.OutputTarget.StdErr + ); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeFalse(), + outcome => outcome.OutcomeRaw.Should().Be(DEFAULT_OUTPUT_TEXT) + ); + } + + [Fact(Timeout = 2_000)] + public async Task ShouldHandleAlternatingWrites() + { + string text = GetMultiLineText(lineCount: 10); + + CliCommand command = CreateCommand( + text, + CliInvocationOptions.OutputTarget.StdOut | CliInvocationOptions.OutputTarget.StdErr + ); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeFalse(), + outcome => outcome.OutcomeRaw.Should().Be(text) + ); + } + + [Fact(Timeout = 2_000)] + public async Task ShouldNotTimeout() + { + string text = GetMultiLineText(lineCount: 1_000); + + CliCommand command = CreateCommand( + text, + CliInvocationOptions.OutputTarget.StdOut | CliInvocationOptions.OutputTarget.StdErr + ); + + _result = await _instance.ExecuteAsync(command, _xunitCancelToken); + + SatisfiesRight( + outcome => outcome.Success.Should().BeFalse(), + outcome => outcome.OutcomeRaw.Should().Be(text) + ); + } + + private void SatisfiesRight(params Action[] assertions) + { + using AssertionScope scope = new(); + + _result.Should().NotBeNull(); + + _result?.Match( + Right: val => + { + foreach (Action assertion in assertions) assertion(val); + }, + Left: val => val.Should().BeNull() + ); + } + + private static string GetMultiLineText(int lineCount = 1_000) + { + string text = string.Join( + Environment.NewLine, + Enumerable.Range(start: 0, lineCount).Select(num => $"This is line {num}.") + ); + + return text; + } + + private static CliCommand CreateCommand( + string text = DEFAULT_OUTPUT_TEXT, + CliInvocationOptions.OutputTarget target = CliInvocationOptions.OutputTarget.StdOut, + int exitCode = 0 + ) + { + CliCommand command = new() + { + FileName = "./Oma.WndwCtrl.Core.Mocks", + Arguments = new CliInvocationOptions + { + Text = text, + TargetStreams = target, + ExitCode = exitCode, + }.ToString(), + }; + + return command; + } +} \ No newline at end of file diff --git a/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Oma.WndwCtrl.Core.IntegrationTests.csproj b/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Oma.WndwCtrl.Core.IntegrationTests.csproj new file mode 100644 index 0000000..7180680 --- /dev/null +++ b/.integrationTests/Oma.WndwCtrl.Core.IntegrationTests/Oma.WndwCtrl.Core.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs b/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs index ebcd83b..8e41dcf 100644 --- a/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs +++ b/.unitTests/Oma.WndwCtrl.Core.Mocks/CliCommandMock.cs @@ -104,5 +104,6 @@ public enum OutputTarget [Option(shortName: 't', "text", Required = true, HelpText = "The text to write to standard out or error.")] public string Text { get; init; } = string.Empty; - public override string ToString() => $"--code {ExitCode} --targetStreams {TargetStreams} --text \"{Text}\""; + public override string ToString() => + $"--code {ExitCode} --targetStreams \"{TargetStreams}\" --text \"{Text}\""; } \ No newline at end of file diff --git a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs index 840c2e4..44223e7 100644 --- a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs +++ b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs @@ -15,6 +15,7 @@ protected FlowError(Error other) : this(other.Message, other.IsExceptional, othe protected FlowError(TechnicalError technicalError) : this((Error)technicalError) { + Inner = technicalError.Inner; } [PublicAPI] @@ -29,9 +30,11 @@ public FlowError(string message, bool isExceptional) : this(message, isException public override Option Inner { get; } = Option.None; - public override ErrorException ToErrorException() => throw - // TODO - new NotImplementedException(); + public override ErrorException ToErrorException() => + Inner.Match( + err => new WrappedErrorExceptionalException(err), + new WrappedErrorExceptionalException(this) + ); [System.Diagnostics.Contracts.Pure] public static FlowError NoCommandExecutorFound(ICommand command) => new( diff --git a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs index 9b2ca1d..dd85446 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs @@ -45,21 +45,29 @@ public async Task> ExecuteAsync( ); } - string errorText = string.Empty; - process.ErrorDataReceived += (sender, e) => { errorText += e.Data; }; + List errorChunks = []; + + process.ErrorDataReceived += (sender, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + errorChunks.Add(e.Data); + } + }; process.BeginErrorReadLine(); string allText = await process.StandardOutput.ReadToEndAsync(cancelToken); await process.WaitForExitAsync(cancelToken); - string outcome = process.ExitCode == 0 ? allText - : string.IsNullOrEmpty(errorText) ? allText : errorText; + string outcome = process.ExitCode == 0 && errorChunks.Count == 0 + ? allText + : string.Join(Environment.NewLine, errorChunks); return Right( new CommandOutcome(outcome) { - Success = process.ExitCode == 0, + Success = process.ExitCode == 0 && errorChunks.Count == 0, } ); } @@ -69,7 +77,9 @@ public async Task> ExecuteAsync( } catch (Exception ex) { - return Left(new TechnicalError("An unexpected technical error has occured.", Code: -1, ex)); + return Left( + new TechnicalError("An unexpected technical error has occured executing CLI command.", Code: -1, ex) + ); } } } \ No newline at end of file diff --git a/Oma.WndwCtrl.sln b/Oma.WndwCtrl.sln index a425f9a..e9e6ffd 100644 --- a/Oma.WndwCtrl.sln +++ b/Oma.WndwCtrl.sln @@ -74,6 +74,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Ext.Windows.Me EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Core.Mocks", ".unitTests\Oma.WndwCtrl.Core.Mocks\Oma.WndwCtrl.Core.Mocks.csproj", "{05B8421B-071F-4C9E-96DC-9978F0AE59F7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Core.IntegrationTests", ".integrationTests\Oma.WndwCtrl.Core.IntegrationTests\Oma.WndwCtrl.Core.IntegrationTests.csproj", "{BECCCB65-C4D8-4AD1-B156-A745370BE36A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -156,6 +158,10 @@ Global {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {05B8421B-071F-4C9E-96DC-9978F0AE59F7}.Release|Any CPU.Build.0 = Release|Any CPU + {BECCCB65-C4D8-4AD1-B156-A745370BE36A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BECCCB65-C4D8-4AD1-B156-A745370BE36A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BECCCB65-C4D8-4AD1-B156-A745370BE36A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BECCCB65-C4D8-4AD1-B156-A745370BE36A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {E7217C2E-5C78-4106-977D-3091CFA63F43} = {307F90ED-24AD-4725-960B-0A7AAFFE8C6D} @@ -171,5 +177,6 @@ Global {4A56D8FD-7965-4FE2-B989-B72A3CB14FB6} = {9AE4447A-201F-4DCF-8715-803610028379} {418214AE-8597-4FAB-A511-C4194AD96E4F} = {4A56D8FD-7965-4FE2-B989-B72A3CB14FB6} {05B8421B-071F-4C9E-96DC-9978F0AE59F7} = {307F90ED-24AD-4725-960B-0A7AAFFE8C6D} + {BECCCB65-C4D8-4AD1-B156-A745370BE36A} = {CB01FB73-6F61-43AA-81EB-C3A19BA74122} EndGlobalSection EndGlobal From 509d6f36d98244fedd0c6655a7d5fd9d30e7b22a Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 09:00:03 +0100 Subject: [PATCH 3/9] Adjust code for inspection findings --- Oma.WndwCtrl.Abstractions/Errors/FlowError.cs | 2 +- .../Executors/Commands/CliCommandExecutor.cs | 9 +++++---- .../Executors/Commands/DummyCommandExecutor.cs | 7 ++++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs index 44223e7..8cf0a86 100644 --- a/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs +++ b/Oma.WndwCtrl.Abstractions/Errors/FlowError.cs @@ -13,7 +13,7 @@ protected FlowError(Error other) : this(other.Message, other.IsExceptional, othe Inner = other; } - protected FlowError(TechnicalError technicalError) : this((Error)technicalError) + public FlowError(TechnicalError technicalError) : this((Error)technicalError) { Inner = technicalError.Inner; } diff --git a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs index dd85446..1c38726 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; @@ -45,9 +46,9 @@ public async Task> ExecuteAsync( ); } - List errorChunks = []; + ConcurrentBag errorChunks = []; - process.ErrorDataReceived += (sender, e) => + process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) { @@ -60,14 +61,14 @@ public async Task> ExecuteAsync( await process.WaitForExitAsync(cancelToken); - string outcome = process.ExitCode == 0 && errorChunks.Count == 0 + string outcome = process.ExitCode == 0 && errorChunks.IsEmpty ? allText : string.Join(Environment.NewLine, errorChunks); return Right( new CommandOutcome(outcome) { - Success = process.ExitCode == 0 && errorChunks.Count == 0, + Success = process.ExitCode == 0 && errorChunks.IsEmpty, } ); } diff --git a/Oma.WndwCtrl.Core/Executors/Commands/DummyCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/DummyCommandExecutor.cs index 28e496a..937bfc1 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/DummyCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/DummyCommandExecutor.cs @@ -27,7 +27,12 @@ public Task> ExecuteAsync( string message = string.Join(Environment.NewLine, command.Returns); - if (command.SimulateFailure) + if (cancelToken.IsCancellationRequested) + { + OperationCancelledError opCancelled = new(new OperationCanceledException()); + result = Left(new FlowError(opCancelled)); + } + else if (command.SimulateFailure) { result = Left(new FlowError(message, command.IsExceptional, command.IsExpected)); } From 2fedb2b237ee56636c419a92ead74e17f8488e19 Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 09:16:49 +0100 Subject: [PATCH 4/9] Adjust workflow to run multi-os tests --- .github/workflows/dotnet.yml | 68 +++++++++++++++++++++++------------- Oma.WndwCtrl.sln | 6 ++++ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3396ffc..697b262 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -9,32 +9,50 @@ on: pull_request: branches: [ "main" ] -jobs: - build: +jobs: + build-and-analyze: name: 'Build & Analyze' - permissions: write-all + permissions: write-all runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore -warnaserror - - name: Test - run: dotnet test --no-build --verbosity normal - - name: JetBrains ReSharper Inspect Code - uses: JetBrains/ReSharper-InspectCode@v0.8 - with: - solution: 'Oma.WndwCtrl.sln' - - name: Upload a Build Artifact - uses: actions/upload-artifact@v4.5.0 - with: - name: 'Upload Code Inspection Result' - path: 'results.sarif.json' - if-no-files-found: error - retention-days: 7 + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore -warnaserror + - name: JetBrains ReSharper Inspect Code + uses: JetBrains/ReSharper-InspectCode@v0.8 + with: + solution: 'Oma.WndwCtrl.sln' + - name: Upload a Build Artifact + uses: actions/upload-artifact@v4.5.0 + with: + name: 'Upload Code Inspection Result' + path: 'results.sarif.json' + if-no-files-found: error + retention-days: 7 + + test-matrix: + name: 'Run Test Matrix' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore -warnaserror + - name: Test + run: dotnet test --no-build --verbosity normal + diff --git a/Oma.WndwCtrl.sln b/Oma.WndwCtrl.sln index e9e6ffd..5f4d5c3 100644 --- a/Oma.WndwCtrl.sln +++ b/Oma.WndwCtrl.sln @@ -76,6 +76,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Core.Mocks", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Oma.WndwCtrl.Core.IntegrationTests", ".integrationTests\Oma.WndwCtrl.Core.IntegrationTests\Oma.WndwCtrl.Core.IntegrationTests.csproj", "{BECCCB65-C4D8-4AD1-B156-A745370BE36A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub Actions", "GitHub Actions", "{E66E5673-DF95-4D11-8FF9-3E814BD40B4B}" + ProjectSection(SolutionItems) = preProject + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -178,5 +183,6 @@ Global {418214AE-8597-4FAB-A511-C4194AD96E4F} = {4A56D8FD-7965-4FE2-B989-B72A3CB14FB6} {05B8421B-071F-4C9E-96DC-9978F0AE59F7} = {307F90ED-24AD-4725-960B-0A7AAFFE8C6D} {BECCCB65-C4D8-4AD1-B156-A745370BE36A} = {CB01FB73-6F61-43AA-81EB-C3A19BA74122} + {E66E5673-DF95-4D11-8FF9-3E814BD40B4B} = {FC063003-91F3-415C-AB68-D1C08337FAC2} EndGlobalSection EndGlobal From 590534b12e52e06290705864c2dc02531ffbe27e Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 09:33:59 +0100 Subject: [PATCH 5/9] Change CBag to CQueue --- Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs index 1c38726..99f0049 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs @@ -46,13 +46,13 @@ public async Task> ExecuteAsync( ); } - ConcurrentBag errorChunks = []; + ConcurrentQueue errorChunks = []; process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) { - errorChunks.Add(e.Data); + errorChunks.Enqueue(e.Data); } }; From d3fce2a88218abd51dd2921d03329f8d8cca3e1a Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 09:49:46 +0100 Subject: [PATCH 6/9] Remove Right-wrap --- .../Executors/Commands/CliCommandExecutor.cs | 17 ++++------------- .../Executors/Commands/MediaCommandExecutor.cs | 18 ++++-------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs index 99f0049..fa66d7b 100644 --- a/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs +++ b/Oma.WndwCtrl.Core/Executors/Commands/CliCommandExecutor.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using LanguageExt; using Oma.WndwCtrl.Abstractions; @@ -14,13 +13,7 @@ namespace Oma.WndwCtrl.Core.Executors.Commands; public class CliCommandExecutor : ICommandExecutor { - [SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "Must be disposed by caller." - )] [MustDisposeResource] - [SuppressMessage("ReSharper", "NotDisposedResource", Justification = "Method flagged as must-dispose.")] public async Task> ExecuteAsync( CliCommand command, CancellationToken cancelToken = default @@ -65,12 +58,10 @@ public async Task> ExecuteAsync( ? allText : string.Join(Environment.NewLine, errorChunks); - return Right( - new CommandOutcome(outcome) - { - Success = process.ExitCode == 0 && errorChunks.IsEmpty, - } - ); + return new CommandOutcome(outcome) + { + Success = process.ExitCode == 0 && errorChunks.IsEmpty, + }; } catch (OperationCanceledException ex) { diff --git a/Oma.WndwCtrl.Extensions/Windows/Oma.WndwCtrl.Ext.Windows.Media/Executors/Commands/MediaCommandExecutor.cs b/Oma.WndwCtrl.Extensions/Windows/Oma.WndwCtrl.Ext.Windows.Media/Executors/Commands/MediaCommandExecutor.cs index c93b2e5..3dd9b9c 100644 --- a/Oma.WndwCtrl.Extensions/Windows/Oma.WndwCtrl.Ext.Windows.Media/Executors/Commands/MediaCommandExecutor.cs +++ b/Oma.WndwCtrl.Extensions/Windows/Oma.WndwCtrl.Ext.Windows.Media/Executors/Commands/MediaCommandExecutor.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using JetBrains.Annotations; using LanguageExt; @@ -6,7 +5,6 @@ using Oma.WndwCtrl.Abstractions.Errors; using Oma.WndwCtrl.Abstractions.Model; using Oma.WndwCtrl.Ext.Windows.Media.Model.Commands; -using static LanguageExt.Prelude; namespace Oma.WndwCtrl.Ext.Windows.Media.Executors.Commands; @@ -20,13 +18,7 @@ public partial class MediaCommandExecutor : ICommandExecutor private const int VK_MEDIA_STOP = 0xB2; private const int VK_MEDIA_PLAY_PAUSE = 0xB3; - [SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "Must be disposed by caller." - )] [MustDisposeResource] - [SuppressMessage("ReSharper", "NotDisposedResource", Justification = "Method flagged as must-dispose.")] public Task> ExecuteAsync( MediaCommand command, CancellationToken cancelToken = default @@ -44,12 +36,10 @@ public Task> ExecuteAsync( keybd_event(keyToSend, scanCode: 0, KEYEVENTF_EXTENTEDKEY, IntPtr.Zero); return Task.FromResult>( - Right( - new CommandOutcome - { - Success = true, - } - ) + new CommandOutcome + { + Success = true, + } ); } From 20932c84daaecadf0b838f5a72e669ecadd42eb6 Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 09:56:12 +0100 Subject: [PATCH 7/9] Change mock approach slighly --- .../MessageBusIntegrationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs index 0e5248a..332d0f8 100644 --- a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs +++ b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs @@ -286,6 +286,7 @@ private static IMessageConsumer AddConsumerToContainer( IMessageConsumer messageConsumer = Substitute.For>(); + messageConsumer.IsSubscribedTo(Arg.Any()).Returns(returnThis: false); messageConsumer.IsSubscribedTo(Arg.Any()).Returns(returnThis: true); services.AddMessageConsumer, TMessage>( From 2cafc71d8c6b99cd9f0c9f99c4f402cb93913d2e Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 10:23:21 +0100 Subject: [PATCH 8/9] Await all, not only one consumer --- .../MessageBusIntegrationTests.cs | 11 ++++++----- Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs index 332d0f8..fa8205a 100644 --- a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs +++ b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -15,7 +16,7 @@ public sealed class MessageBusIntegrationTests : IAsyncLifetime private readonly List _consumerProviders = []; private readonly ServiceProvider _serviceProvider = SetUpMessageBusContainer(); - private Task? _consumerTask; + private ConcurrentBag _consumerTasks = []; private IMessageBus? _messageBus; @@ -61,7 +62,7 @@ public async Task ShouldFanOutMessages() private async Task WaitForConsumerCompletion() { - await (_consumerTask ?? Task.CompletedTask); + await Task.WhenAll(_consumerTasks); } [Fact] @@ -96,7 +97,7 @@ public async Task ShouldRouteMessagesByType() IMessageConsumer otherC = AddConsumerToContainer(services); ServiceProvider provider = services.BuildServiceProvider(); - _consumerTask = provider.StartConsumersAsync(MessageBus, _cancelToken); + _consumerTasks.Add(provider.StartConsumersAsync(MessageBus, _cancelToken)); await MessageBus.SendAsync(new DummyMessage(), _cancelToken); await MessageBus.SendAsync(new DummyMessage(), _cancelToken); @@ -271,7 +272,7 @@ private IMessageConsumer SetUpConsumerContainer( ServiceProvider result = services.BuildServiceProvider(); _consumerProviders.Add(result); - _consumerTask = result.StartConsumersAsync(MessageBus, cancelToken ?? _cancelToken); + _consumerTasks.Add(result.StartConsumersAsync(MessageBus, cancelToken ?? _cancelToken)); return messageConsumer; } diff --git a/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json b/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json index efbbd36..5be049e 100644 --- a/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json +++ b/Oma.WndwCtrl.MgmtApi/MgmtApiService.config.json @@ -9,7 +9,7 @@ }, "SchedulingService": { "CheckInterval": "00:00:00.001", - "Active": false + "Active": true }, "EventLoggingService": { "Enabled": false From 796c9cf850847b1b2f0bf62d52ae3b7d750ee322 Mon Sep 17 00:00:00 2001 From: OlliMartin Date: Thu, 30 Jan 2025 10:25:54 +0100 Subject: [PATCH 9/9] Inspection finding fixed --- .../MessageBusIntegrationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs index fa8205a..4f832a8 100644 --- a/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs +++ b/.integrationTests/Oma.WndwCtrl.Messaging.IntegrationTests/MessageBusIntegrationTests.cs @@ -14,9 +14,9 @@ public sealed class MessageBusIntegrationTests : IAsyncLifetime private readonly CancellationToken _cancelToken = TestContext.Current.CancellationToken; private readonly List _consumerProviders = []; - private readonly ServiceProvider _serviceProvider = SetUpMessageBusContainer(); - private ConcurrentBag _consumerTasks = []; + private readonly ConcurrentBag _consumerTasks = []; + private readonly ServiceProvider _serviceProvider = SetUpMessageBusContainer(); private IMessageBus? _messageBus;