From c7a1d3650464967ff66912c37d6e720f238d9113 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 12 May 2026 22:17:29 +0100 Subject: [PATCH 1/2] Switch spectre logger to MEL.Spectre --- Directory.Packages.props | 6 +-- .../Console/DelegatingAnsiConsole.cs | 2 + .../DependencyInjectionSetup.cs | 47 +++---------------- src/ModularPipelines/ModularPipelines.csproj | 4 +- src/ModularPipelines/PipelineBuilder.cs | 2 - .../Commands/CommandLoggerTests.cs | 5 +- .../Context/HttpTests.cs | 4 -- 7 files changed, 15 insertions(+), 55 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9835dff429..4aef5a26f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + @@ -84,7 +84,7 @@ - + @@ -93,4 +93,4 @@ - \ No newline at end of file + diff --git a/src/ModularPipelines/Console/DelegatingAnsiConsole.cs b/src/ModularPipelines/Console/DelegatingAnsiConsole.cs index ca25903862..f4ab5b04b3 100644 --- a/src/ModularPipelines/Console/DelegatingAnsiConsole.cs +++ b/src/ModularPipelines/Console/DelegatingAnsiConsole.cs @@ -30,5 +30,7 @@ private DelegatingAnsiConsole() public void Clear(bool home) => Console.Clear(home); + public void WriteAnsi(Action action) => Console.WriteAnsi(action); + public void Write(IRenderable renderable) => Console.Write(renderable); } diff --git a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs index 9ef586b179..c319d5e162 100644 --- a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs +++ b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs @@ -1,4 +1,5 @@ using Initialization.Microsoft.Extensions.DependencyInjection.Extensions; +using MEL.Spectre; using Mediator; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -32,8 +33,7 @@ using ModularPipelines.Engine.State; using ModularPipelines.Validation; using ModularPipelines.Console; -using Vertical.SpectreLogger; -using Vertical.SpectreLogger.Options; +using Spectre.Console; namespace ModularPipelines.DependencyInjection; @@ -81,48 +81,15 @@ private static void RegisterBundledServices(IServiceCollection services) { // Use delegating console that forwards to current AnsiConsole.Console // This allows ConsoleCoordinator to replace the console instance later - // while SpectreLogger continues to use the correct one + // while MEL.Spectre continues to use the correct one // Note: Console width is configured by ConsoleCoordinator.ConfigureConsoleWidth() - config.UseConsole(DelegatingAnsiConsole.Instance); + config.Console = DelegatingAnsiConsole.Instance; - // Configure output templates without timestamps + // Configure output without timestamps // Command logging already includes precise timestamps ([HH:mm:ss.fff]) // so we don't need them from the logger as well - config.ConfigureProfile(LogLevel.Trace, profile => - { - profile.OutputTemplate = "[grey][[Trace]][/] {Message}{NewLine}{Exception}"; - }); - config.ConfigureProfile(LogLevel.Debug, profile => - { - profile.OutputTemplate = "[grey][[Debug]][/] {Message}{NewLine}{Exception}"; - }); - config.ConfigureProfile(LogLevel.Information, profile => - { - profile.OutputTemplate = "[deepskyblue1][[Info]][/] {Message}{NewLine}{Exception}"; - }); - config.ConfigureProfile(LogLevel.Warning, profile => - { - profile.OutputTemplate = "[yellow][[Warn]][/] {Message}{NewLine}{Exception}"; - }); - config.ConfigureProfile(LogLevel.Error, profile => - { - profile.OutputTemplate = "[red][[Error]][/] {Message}{NewLine}{Exception}"; - }); - config.ConfigureProfile(LogLevel.Critical, profile => - { - profile.OutputTemplate = "[white on red][[Critical]][/] {Message}{NewLine}{Exception}"; - }); - - config.ConfigureProfiles(profile => - { - // Enable Spectre.Console markup rendering in log messages - profile.PreserveMarkupInFormatStrings = true; - - profile.ConfigureOptions(options => - { - options.MaxStackFrames = int.MaxValue; - }); - }); + config.Template = "[{Level:u4}] {Message}"; + config.ExceptionFormats = ExceptionFormats.Default; }); }) .AddHttpClient() diff --git a/src/ModularPipelines/ModularPipelines.csproj b/src/ModularPipelines/ModularPipelines.csproj index 0b60f8cc34..f8fb71b81f 100644 --- a/src/ModularPipelines/ModularPipelines.csproj +++ b/src/ModularPipelines/ModularPipelines.csproj @@ -73,7 +73,7 @@ - + @@ -95,4 +95,4 @@ - \ No newline at end of file + diff --git a/src/ModularPipelines/PipelineBuilder.cs b/src/ModularPipelines/PipelineBuilder.cs index cf955331ca..1793c528ef 100644 --- a/src/ModularPipelines/PipelineBuilder.cs +++ b/src/ModularPipelines/PipelineBuilder.cs @@ -10,7 +10,6 @@ using ModularPipelines.Options; using ModularPipelines.Plugins; using ModularPipelines.Validation; -using Vertical.SpectreLogger.Options; namespace ModularPipelines; @@ -89,7 +88,6 @@ public IHostEnvironment Environment /// The same builder instance for chaining. public PipelineBuilder SetLogLevel(LogLevel logLevel) { - _services.Configure(options => options.MinimumLogLevel = logLevel); _services.Configure(options => options.MinLevel = logLevel); return this; } diff --git a/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs b/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs index cd4594ff06..dae200cb36 100644 --- a/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs +++ b/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs @@ -5,7 +5,6 @@ using ModularPipelines.Options; using ModularPipelines.TestHelpers; using NReco.Logging.File; -using Vertical.SpectreLogger.Options; using LogLevel = Microsoft.Extensions.Logging.LogLevel; namespace ModularPipelines.UnitTests.Commands; @@ -82,7 +81,6 @@ private async Task RunPowershellCommand(string command, bool logInput, b var result = await GetService((_, collection) => { - collection.Configure(options => options.MinimumLogLevel = LogLevel.Information); collection.Configure(options => options.MinLevel = LogLevel.Information); collection.AddLogging(builder => { builder.AddFile(file); }); }); @@ -208,7 +206,6 @@ private async Task RunPowershellCommandWithLoggingOptions(string command var result = await GetService((_, collection) => { - collection.Configure(options => options.MinimumLogLevel = LogLevel.Information); collection.Configure(options => options.MinLevel = LogLevel.Information); collection.AddLogging(builder => { builder.AddFile(file); }); }); @@ -225,4 +222,4 @@ await result.T.ExecuteCommandLineTool( return file; } -} \ No newline at end of file +} diff --git a/test/ModularPipelines.UnitTests/Context/HttpTests.cs b/test/ModularPipelines.UnitTests/Context/HttpTests.cs index 2096c35fac..c077e3a441 100644 --- a/test/ModularPipelines.UnitTests/Context/HttpTests.cs +++ b/test/ModularPipelines.UnitTests/Context/HttpTests.cs @@ -4,7 +4,6 @@ using ModularPipelines.Options; using ModularPipelines.TestHelpers; using NReco.Logging.File; -using Vertical.SpectreLogger.Options; using File = System.IO.File; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -31,7 +30,6 @@ public async Task When_Log_Request_False_Then_Do_Not_Log_Request() { collection.AddLogging(builder => { - collection.Configure(options => options.MinimumLogLevel = LogLevel.Information); collection.Configure(options => options.MinLevel = LogLevel.Information); builder.AddFile(file); }); @@ -61,7 +59,6 @@ public async Task When_Log_Response_False_Then_Do_Not_Log_Response() { collection.AddLogging(builder => { - collection.Configure(options => options.MinimumLogLevel = LogLevel.Information); collection.Configure(options => options.MinLevel = LogLevel.Information); builder.AddFile(file); }); @@ -93,7 +90,6 @@ public async Task Assert_LoggingHttpClient_Logs_As_Expected(bool customHttpClien { collection.AddLogging(builder => { - collection.Configure(options => options.MinimumLogLevel = LogLevel.Information); collection.Configure(options => options.MinLevel = LogLevel.Information); builder.AddFile(file); }); From 16b7a7a39116517bbe77425b3208627b827791e6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 12 May 2026 23:36:52 +0100 Subject: [PATCH 2/2] Fix CI console output polish Fixes #2620 Fixes #2621 Fixes #2622 Fixes #2623 --- Directory.Packages.props | 2 +- .../PrintEnvironmentVariablesModule.cs | 31 +++- .../Modules/RunUnitTestsModule.cs | 137 +++++++++++++++++- .../Parsers/Trx/TestOutput.cs | 5 +- .../Parsers/Trx/TrxParser.cs | 3 +- .../DependencyInjectionSetup.cs | 1 + .../Helpers/SpectreResultsPrinter.cs | 15 +- .../Helpers/TimeSpanFormatter.cs | 8 +- src/ModularPipelines/Logging/CommandLogger.cs | 19 ++- .../Commands/CommandLoggerTests.cs | 4 +- .../Models/TrxParsingTests.cs | 3 + 11 files changed, 192 insertions(+), 36 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4aef5a26f2..ff745d357a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,7 +84,7 @@ - + diff --git a/src/ModularPipelines.Build/Modules/PrintEnvironmentVariablesModule.cs b/src/ModularPipelines.Build/Modules/PrintEnvironmentVariablesModule.cs index 6848448f5e..5b46d14af1 100644 --- a/src/ModularPipelines.Build/Modules/PrintEnvironmentVariablesModule.cs +++ b/src/ModularPipelines.Build/Modules/PrintEnvironmentVariablesModule.cs @@ -1,8 +1,7 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using ModularPipelines.Build.Helpers; +using ModularPipelines; using ModularPipelines.Context; using ModularPipelines.Modules; +using Spectre.Console; namespace ModularPipelines.Build.Modules; @@ -10,8 +9,28 @@ public class PrintEnvironmentVariablesModule : Module?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) { - context.Logger.LogInformation("Environment Variables: {EnvVars}", JsonSerializer.Serialize(context.Environment.Variables.GetEnvironmentVariables(), DiagnosticSerializerOptions.Instance)); + var environmentVariables = context.Environment.Variables.GetEnvironmentVariables() + .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); - return Task.FromResult?>(null); + var table = new Table + { + Border = TableBorder.Rounded, + }; + + table.AddColumn(new TableColumn("[bold]Name[/]").LeftAligned()); + table.AddColumn(new TableColumn("[bold]Value[/]").LeftAligned()); + + foreach (var environmentVariable in environmentVariables) + { + table.AddRow( + Markup.Escape(environmentVariable.Key), + Markup.Escape(environmentVariable.Value)); + } + + ((IConsoleWriter)context.Logger).Write(table); + + return Task.FromResult?>( + environmentVariables.ToDictionary(x => x.Key, x => (object)x.Value)); } -} \ No newline at end of file +} diff --git a/src/ModularPipelines.Build/Modules/RunUnitTestsModule.cs b/src/ModularPipelines.Build/Modules/RunUnitTestsModule.cs index 8c4a6a389f..689015e3ca 100644 --- a/src/ModularPipelines.Build/Modules/RunUnitTestsModule.cs +++ b/src/ModularPipelines.Build/Modules/RunUnitTestsModule.cs @@ -1,16 +1,21 @@ using EnumerableAsyncProcessor.Extensions; using Microsoft.Extensions.Options; +using ModularPipelines; using ModularPipelines.Attributes; using ModularPipelines.Build.Settings; using ModularPipelines.Configuration; using ModularPipelines.Context; +using ModularPipelines.DotNet.Enums; using ModularPipelines.DotNet.Extensions; using ModularPipelines.DotNet.Options; +using ModularPipelines.DotNet.Parsers.Trx; using ModularPipelines.Git.Extensions; using ModularPipelines.Models; using ModularPipelines.Modules; using ModularPipelines.Options; using Polly; +using Spectre.Console; +using File = ModularPipelines.FileSystem.File; namespace ModularPipelines.Build.Modules; @@ -29,16 +34,30 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) { - return await context.Git().RootDirectory + var testFramework = _pipelineSettings.Value.TestFramework; + var unitTestProjects = context.Git().RootDirectory .GetFiles(file => file.Path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) && file.Path.Contains("UnitTests", StringComparison.OrdinalIgnoreCase)) + .Select(file => new UnitTestProject(file, testFramework, $"{file.NameWithoutExtension}.trx")) + .ToList(); + + var commandResults = await unitTestProjects .ToAsyncProcessorBuilder() - .SelectAsync(async unitTestProjectFile => await context.DotNet().Run(new DotNetRunOptions + .SelectAsync(async unitTestProject => await context.DotNet().Run(new DotNetRunOptions { - Project = unitTestProjectFile.Path, + Project = unitTestProject.ProjectFile.Path, NoBuild = true, - Framework = _pipelineSettings.Value.TestFramework, - Arguments = ["--coverage", "--coverage-output-format", "cobertura", "--hangdump", "--hangdump-timeout", "20m"], + Framework = testFramework, + Arguments = + [ + "--", + "--coverage", + "--coverage-output-format", "cobertura", + "--hangdump", + "--hangdump-timeout", "20m", + "--report-trx", + "--report-trx-filename", unitTestProject.TrxFileName + ], Configuration = "Release", Properties = [ @@ -53,8 +72,116 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create ["GITHUB_ACTIONS"] = null, ["GITHUB_STEP_SUMMARY"] = null, }, + OutputLoggingManipulator = RemoveSkippedTestDump, }, cancellationToken)) .ProcessInParallel(); + + await PrintSkippedTestsAsync(context, unitTestProjects, cancellationToken); + + return commandResults; } + + private static async Task PrintSkippedTestsAsync( + IModuleContext context, + IReadOnlyCollection unitTestProjects, + CancellationToken cancellationToken) + { + var skippedTests = new List(); + + foreach (var unitTestProject in unitTestProjects) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!unitTestProject.TrxFile.Exists) + { + continue; + } + + var testResult = await context.Trx().ParseTrxFile(unitTestProject.TrxFile); + + skippedTests.AddRange(testResult.UnitTestResults + .Where(test => test.Outcome == TestOutcome.NotExecuted) + .Select(test => new SkippedTest( + unitTestProject.ProjectName, + test.TestName ?? "-", + GetSkippedReason(test)))); + } + + if (skippedTests.Count == 0) + { + return; + } + + var table = new Table + { + Border = TableBorder.Rounded, + }; + + table.AddColumn(new TableColumn("[bold]Project[/]").LeftAligned()); + table.AddColumn(new TableColumn("[bold]Skipped Test[/]").LeftAligned()); + table.AddColumn(new TableColumn("[bold]Reason[/]").LeftAligned()); + + foreach (var skippedTest in skippedTests + .OrderBy(x => x.Project, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Test, StringComparer.OrdinalIgnoreCase)) + { + table.AddRow( + Markup.Escape(skippedTest.Project), + Markup.Escape(skippedTest.Test), + Markup.Escape(skippedTest.Reason)); + } + + ((IConsoleWriter)context.Logger).Write(table); + } + + private static string GetSkippedReason(UnitTestResult test) + { + var reason = test.Output?.DebugTrace + ?? test.Output?.ErrorInfo?.Message + ?? test.Output?.StdOut; + + if (string.IsNullOrWhiteSpace(reason)) + { + return "-"; + } + + return reason.Replace("Skipped:", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(); + } + + private static string RemoveSkippedTestDump(string output) + { + var filteredLines = new List(); + var skippingSkippedBlock = false; + + foreach (var line in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("skipped ", StringComparison.OrdinalIgnoreCase)) + { + skippingSkippedBlock = true; + continue; + } + + if (skippingSkippedBlock && char.IsWhiteSpace(line[0])) + { + continue; + } + + skippingSkippedBlock = false; + filteredLines.Add(line); + } + + return string.Join(Environment.NewLine, filteredLines); + } + + private sealed record UnitTestProject(File ProjectFile, string Framework, string TrxFileName) + { + public string ProjectName => ProjectFile.NameWithoutExtension; + + public File TrxFile => ProjectFile.Folder! + .GetFolder(Path.Combine("bin", "Release", Framework, "TestResults")) + .GetFile(TrxFileName); + } + + private sealed record SkippedTest(string Project, string Test, string Reason); } diff --git a/src/ModularPipelines.DotNet/Parsers/Trx/TestOutput.cs b/src/ModularPipelines.DotNet/Parsers/Trx/TestOutput.cs index 9a6b21677f..11bf0eed49 100644 --- a/src/ModularPipelines.DotNet/Parsers/Trx/TestOutput.cs +++ b/src/ModularPipelines.DotNet/Parsers/Trx/TestOutput.cs @@ -10,6 +10,9 @@ public record TestOutput [XmlElement("StdOut")] public string? StdOut { get; init; } + [XmlElement("DebugTrace")] + public string? DebugTrace { get; init; } + [XmlElement("ErrorInfo")] public ErrorInfo? ErrorInfo { get; init; } -} \ No newline at end of file +} diff --git a/src/ModularPipelines.DotNet/Parsers/Trx/TrxParser.cs b/src/ModularPipelines.DotNet/Parsers/Trx/TrxParser.cs index 81724bb25f..fda2d9ee48 100644 --- a/src/ModularPipelines.DotNet/Parsers/Trx/TrxParser.cs +++ b/src/ModularPipelines.DotNet/Parsers/Trx/TrxParser.cs @@ -77,6 +77,7 @@ private UnitTestResult ParseUnitTestResult(XElement element) Output = new TestOutput { StdOut = element.Descendants().FirstOrDefault(x => x.Name.LocalName == "StdOut")?.Value, + DebugTrace = element.Descendants().FirstOrDefault(x => x.Name.LocalName == "DebugTrace")?.Value, ErrorInfo = errorInfo == null ? null : new ErrorInfo { Message = errorInfo.Descendants().FirstOrDefault(x => x.Name.LocalName == "Message")?.Value, @@ -85,4 +86,4 @@ private UnitTestResult ParseUnitTestResult(XElement element) }, }; } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs index c319d5e162..f75bcb967b 100644 --- a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs +++ b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs @@ -89,6 +89,7 @@ private static void RegisterBundledServices(IServiceCollection services) // Command logging already includes precise timestamps ([HH:mm:ss.fff]) // so we don't need them from the logger as well config.Template = "[{Level:u4}] {Message}"; + config.AllowMarkupInMessageTemplate = true; config.ExceptionFormats = ExceptionFormats.Default; }); }) diff --git a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs index 19aa00735e..840fc49601 100644 --- a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs +++ b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs @@ -125,7 +125,6 @@ private static Table CreateModulesTable(PipelineSummary pipelineSummary) var table = new Table { Border = TableBorder.Rounded, - Expand = true, }; // Add columns with alignment @@ -174,8 +173,6 @@ private static Table CreateModulesTable(PipelineSummary pipelineSummary) AddModuleRow(table, module, timelineLookup); } - // Add separator and total row - table.AddEmptyRow(); AddTotalRow(table, pipelineSummary); return table; @@ -248,7 +245,7 @@ private static string FormatStatusWithIcon(ModuleStatus status) ModuleStatus.TimedOut => "[red]Timeout[/]", ModuleStatus.PipelineTerminated => "[red]Terminated[/]", ModuleStatus.IgnoredFailure => "[yellow]Ignored[/]", - ModuleStatus.Skipped => "[yellow]Skipped[/]", + ModuleStatus.Skipped => "[dim]⏭ skipped[/]", ModuleStatus.UsedHistory => "[green3]Cached[/]", ModuleStatus.Retried => "[yellow]Retried[/]", ModuleStatus.Processing => "[blue]Running[/]", @@ -287,11 +284,17 @@ private static void PrintMetrics(PipelineSummary pipelineSummary) System.Console.WriteLine(); // Create a compact metrics display + var savedTime = metrics.TotalModuleExecutionTime - metrics.WallClockDuration; + if (savedTime < TimeSpan.Zero) + { + savedTime = TimeSpan.Zero; + } + var metricsPanel = new Panel( new Markup( - $"[dim]Parallelism:[/] [bold]{metrics.ParallelismFactor:F1}x[/] " + + $"[dim]Speedup:[/] [bold]{metrics.ParallelismFactor:F1}x[/] " + $"[dim]Peak:[/] [bold]{metrics.PeakConcurrency}[/] " + - $"[dim]Saved:[/] [bold]{(metrics.TotalModuleExecutionTime - metrics.WallClockDuration).ToDisplayString()}[/]")) + $"[dim]Saved:[/] [bold]{savedTime.ToDisplayString()}[/]")) { Border = BoxBorder.None, Padding = new Padding(0, 0, 0, 0), diff --git a/src/ModularPipelines/Helpers/TimeSpanFormatter.cs b/src/ModularPipelines/Helpers/TimeSpanFormatter.cs index 2555f8e558..52a3beacc7 100644 --- a/src/ModularPipelines/Helpers/TimeSpanFormatter.cs +++ b/src/ModularPipelines/Helpers/TimeSpanFormatter.cs @@ -11,15 +11,15 @@ public static string ToDisplayString(this TimeSpan timeSpan) if (timeSpan.TotalMinutes < 1) { - return $"{Seconds(timeSpan)} & {Milliseconds(timeSpan)}"; + return $"{Seconds(timeSpan)} {Milliseconds(timeSpan)}"; } if (timeSpan.TotalHours < 1) { - return $"{Minutes(timeSpan)} & {Seconds(timeSpan)}"; + return $"{Minutes(timeSpan)} {Seconds(timeSpan)}"; } - return $"{Hours(timeSpan)} & {Minutes(timeSpan)}"; + return $"{Hours(timeSpan)} {Minutes(timeSpan)}"; } private static string Milliseconds(TimeSpan timeSpan) @@ -41,4 +41,4 @@ private static string Hours(TimeSpan timeSpan) { return timeSpan.Hours.ToString("0") + "h"; } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Logging/CommandLogger.cs b/src/ModularPipelines/Logging/CommandLogger.cs index aa004c788e..fabbc85663 100644 --- a/src/ModularPipelines/Logging/CommandLogger.cs +++ b/src/ModularPipelines/Logging/CommandLogger.cs @@ -98,8 +98,13 @@ private void LogCompact( mainLine.Append("> "); mainLine.Append(obfuscatedInput); - // Add inline output for short, single-line output on successful commands var trimmedOutput = standardOutput.Trim(); + if (execOpts?.OutputLoggingManipulator is not null) + { + trimmedOutput = execOpts.OutputLoggingManipulator(trimmedOutput).Trim(); + } + + // Add inline output for short, single-line output on successful commands var hasShortOutput = !string.IsNullOrEmpty(trimmedOutput) && !trimmedOutput.Contains('\n') && trimmedOutput.Length <= 100 @@ -155,10 +160,7 @@ private void LogCompact( && options.Verbosity >= CommandLogVerbosity.Normal && options.ShowStandardOutput) { - var outputToLog = execOpts?.OutputLoggingManipulator is not null - ? execOpts.OutputLoggingManipulator(trimmedOutput) - : trimmedOutput; - Logger.LogInformation(" ↳ {Output}", _secretObfuscator.Obfuscate(outputToLog, execOpts)); + Logger.LogInformation(" ↳ {Output}", _secretObfuscator.Obfuscate(trimmedOutput, execOpts)); } // Log errors on separate line @@ -173,11 +175,8 @@ private void LogCompact( Logger.LogWarning(" ✗ {Error}", _secretObfuscator.Obfuscate(errorToLog, execOpts)); } - // Log working directory only at Diagnostic level (separate line, indented) - if (options.Verbosity >= CommandLogVerbosity.Diagnostic || options.ShowWorkingDirectory) - { - Logger.LogInformation(" Working Directory: {WorkingDirectory}", workingDirectory); - } + // The command line already starts with the working directory; keep command output to + // one logical log entry whenever stdout can be displayed inline. } private static bool ShouldShowInput(CommandLoggingOptions options) diff --git a/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs b/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs index dae200cb36..07395d534c 100644 --- a/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs +++ b/test/ModularPipelines.UnitTests/Commands/CommandLoggerTests.cs @@ -196,8 +196,8 @@ public async Task Diagnostic_Verbosity_Logs_Everything_Including_WorkingDirector // Exit code and duration shown inline await Assert.That(logFile).Contains("exit "); await Assert.That(Regex.IsMatch(logFile, @"\[\d+m?s")).IsTrue(); - // Working directory logged separately at Diagnostic level - await Assert.That(logFile).Contains("Working Directory:"); + // Working directory is included in the command line instead of being logged separately. + await Assert.That(logFile).DoesNotContain("Working Directory:"); } private async Task RunPowershellCommandWithLoggingOptions(string command, CommandLoggingOptions loggingOptions) diff --git a/test/ModularPipelines.UnitTests/Models/TrxParsingTests.cs b/test/ModularPipelines.UnitTests/Models/TrxParsingTests.cs index 36f034f194..7d78116510 100644 --- a/test/ModularPipelines.UnitTests/Models/TrxParsingTests.cs +++ b/test/ModularPipelines.UnitTests/Models/TrxParsingTests.cs @@ -80,6 +80,9 @@ await Assert.That(testResult.UnitTestResults.Where(x => x.Outcome == TestOutcome await Assert.That(testResult.UnitTestResults.Where(x => x.Outcome == TestOutcome.NotExecuted)) .HasCount().EqualTo(1); + await Assert.That(testResult.UnitTestResults.Single(x => x.Outcome == TestOutcome.NotExecuted).Output?.DebugTrace) + .Contains("Ignoring this test"); + await Assert.That(testResult.UnitTestResults.Where(x => x.Outcome == TestOutcome.Passed)) .HasCount().EqualTo(2); }