Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageVersion Include="Slack.Webhooks" Version="1.1.5" />
<PackageVersion Include="Sourcy.DotNet" Version="1.1.1" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.6" />
<PackageVersion Include="TestableIO.System.IO.Abstractions" Version="22.1.1" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageVersion Include="TUnit" Version="1.44.0" />
<PackageVersion Include="TUnit.Assertions" Version="1.44.0" />
<PackageVersion Include="TUnit.Core" Version="1.44.0" />
<PackageVersion Include="vertical-spectreconsolelogger" Version="0.10.1-dev.20241201.35" />
<PackageVersion Include="MEL.Spectre" Version="0.4.0" />
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.59.0" />
Expand All @@ -93,4 +93,4 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
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;

public class PrintEnvironmentVariablesModule : Module<IDictionary<string, object>>
{
protected override Task<IDictionary<string, object>?> 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<IDictionary<string, object>?>(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<IDictionary<string, object>?>(
environmentVariables.ToDictionary(x => x.Key, x => (object)x.Value));
}
}
}
137 changes: 132 additions & 5 deletions src/ModularPipelines.Build/Modules/RunUnitTestsModule.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -29,16 +34,30 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create

protected override async Task<CommandResult[]?> 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 =
[
Expand All @@ -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<UnitTestProject> unitTestProjects,
CancellationToken cancellationToken)
{
var skippedTests = new List<SkippedTest>();

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<string>();
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);
}
5 changes: 4 additions & 1 deletion src/ModularPipelines.DotNet/Parsers/Trx/TestOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
3 changes: 2 additions & 1 deletion src/ModularPipelines.DotNet/Parsers/Trx/TrxParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -85,4 +86,4 @@ private UnitTestResult ParseUnitTestResult(XElement element)
},
};
}
}
}
2 changes: 2 additions & 0 deletions src/ModularPipelines/Console/DelegatingAnsiConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ private DelegatingAnsiConsole()

public void Clear(bool home) => Console.Clear(home);

public void WriteAnsi(Action<AnsiWriter> action) => Console.WriteAnsi(action);

public void Write(IRenderable renderable) => Console.Write(renderable);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Initialization.Microsoft.Extensions.DependencyInjection.Extensions;
using MEL.Spectre;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -81,48 +81,16 @@ 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<Vertical.SpectreLogger.Rendering.ExceptionRenderer.Options>(options =>
{
options.MaxStackFrames = int.MaxValue;
});
});
config.Template = "[{Level:u4}] {Message}";
config.AllowMarkupInMessageTemplate = true;
config.ExceptionFormats = ExceptionFormats.Default;
});
})
.AddHttpClient()
Expand Down
15 changes: 9 additions & 6 deletions src/ModularPipelines/Helpers/SpectreResultsPrinter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ private static Table CreateModulesTable(PipelineSummary pipelineSummary)
var table = new Table
{
Border = TableBorder.Rounded,
Expand = true,
};

// Add columns with alignment
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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[/]",
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading