diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs new file mode 100644 index 00000000..c5b00175 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace TALXIS.CLI.Features.Environment.Solution; + +internal enum BuildOutputSeverity +{ + Info, + Warning, + Error, +} + +/// +/// Helpers for interpreting dotnet build output and locating the solution ZIP it produced. +/// +internal static partial class SolutionBuildOutput +{ + [GeneratedRegex(@"\berror(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)] + private static partial Regex ErrorPattern(); + + // Older Build SDKs pack an incomplete zip and exit 0 when root components have no source + // files; the only trace is one of these packager lines, printed without any Error: prefix. + [GeneratedRegex(@"^\s*(Following root components are not defined in customizations|Following objects, required by the solution, are not present)", RegexOptions.IgnoreCase)] + private static partial Regex MissingRootComponentsPattern(); + + [GeneratedRegex(@"\bwarning(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)] + private static partial Regex WarningPattern(); + + internal static BuildOutputSeverity Classify(string line) + { + if (ErrorPattern().IsMatch(line)) return BuildOutputSeverity.Error; + if (MissingRootComponentsPattern().IsMatch(line)) return BuildOutputSeverity.Error; + if (WarningPattern().IsMatch(line)) return BuildOutputSeverity.Warning; + + return BuildOutputSeverity.Info; + } + + /// + /// Finds solution ZIPs under the build output directory (any target framework subfolder). + /// Fresh ZIPs are those written at or after , newest first. + /// + internal static (string[] Fresh, string[] All) FindSolutionZips(string binConfigDir, DateTime buildStartUtc) + { + if (!Directory.Exists(binConfigDir)) return (Array.Empty(), Array.Empty()); + + var all = Directory.GetFiles(binConfigDir, "*.zip", SearchOption.AllDirectories); + var fresh = all + .Where(f => File.GetLastWriteTimeUtc(f) >= buildStartUtc) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToArray(); + + return (fresh, all); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs index 6f28374b..2716571a 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs @@ -199,7 +199,7 @@ protected override async Task ExecuteAsync() } /// - /// Runs dotnet build on a Build SDK project and locates the output ZIP. + /// Runs dotnet build on a Build SDK project and locates the ZIP produced by that build. /// Returns the ZIP path on success, or null on failure. /// private async Task BuildAndLocateZipAsync(string csProjPath) @@ -207,6 +207,9 @@ protected override async Task ExecuteAsync() var config = Managed ? "Release" : "Debug"; Logger.LogInformation("Building '{Project}' with configuration '{Config}'...", Path.GetFileName(csProjPath), config); + var buildStartUtc = DateTime.UtcNow; + var errorLineCount = 0; + var psi = new ProcessStartInfo { FileName = "dotnet", @@ -219,7 +222,23 @@ protected override async Task ExecuteAsync() }; using var process = new Process { StartInfo = psi }; - process.OutputDataReceived += (_, e) => { if (e.Data is not null) Logger.LogInformation("{Line}", e.Data); }; + process.OutputDataReceived += (_, e) => + { + if (e.Data is null) return; + switch (SolutionBuildOutput.Classify(e.Data)) + { + case BuildOutputSeverity.Error: + Interlocked.Increment(ref errorLineCount); + Logger.LogError("{Line}", e.Data); + break; + case BuildOutputSeverity.Warning: + Logger.LogWarning("{Line}", e.Data); + break; + default: + Logger.LogInformation("{Line}", e.Data); + break; + } + }; process.ErrorDataReceived += (_, e) => { if (e.Data is not null) Logger.LogWarning("{Line}", e.Data); }; process.Start(); process.BeginOutputReadLine(); @@ -232,28 +251,31 @@ protected override async Task ExecuteAsync() return null; } - // Build SDK convention: output ZIP is in bin/{config}/net462/*.zip - var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config, "net462"); - if (!Directory.Exists(outputDir)) + // Older Build SDK versions report packager failures but still exit 0 (tools-devkit-build#47). + if (errorLineCount > 0) { - Logger.LogError("Build output directory not found: {OutputDir}.", outputDir); + Logger.LogError("Build succeeded but its output contains {Count} error line(s) (see above). Refusing to import.", errorLineCount); return null; } - var zipFiles = Directory.GetFiles(outputDir, "*.zip"); - if (zipFiles.Length == 0) + var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config); + var (freshZips, allZips) = SolutionBuildOutput.FindSolutionZips(outputDir, buildStartUtc); + + if (allZips.Length == 0) { Logger.LogError("No .zip file found in build output directory: {OutputDir}.", outputDir); return null; } - // Pick the most recently written ZIP to avoid using stale build artifacts - var zipPath = zipFiles - .OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc) - .First(); + if (freshZips.Length == 0) + { + Logger.LogError("The build did not produce a solution ZIP; only stale artifacts from a previous build exist in '{OutputDir}'. Refusing to import a stale ZIP.", outputDir); + return null; + } - if (zipFiles.Length > 1) - Logger.LogWarning("Multiple .zip files found in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath)); + var zipPath = freshZips[0]; + if (freshZips.Length > 1) + Logger.LogWarning("Multiple .zip files produced in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath)); Logger.LogInformation("Using build output: {ZipPath}", zipPath); return zipPath; diff --git a/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs b/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs new file mode 100644 index 00000000..f33d1cd1 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs @@ -0,0 +1,88 @@ +using TALXIS.CLI.Features.Environment.Solution; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Solution; + +public class SolutionBuildOutputTests +{ + [Theory] + [InlineData(" Error: RootComponent validation failed.", nameof(BuildOutputSeverity.Error))] + [InlineData(@"C:\proj\proj.csproj(4,5): error MSB4018: task failed", nameof(BuildOutputSeverity.Error))] + [InlineData("MSBUILD : error MSB1009: Project file does not exist.", nameof(BuildOutputSeverity.Error))] + [InlineData("Following root components are not defined in customizations:", nameof(BuildOutputSeverity.Error))] + [InlineData(" Following objects, required by the solution, are not present. ", nameof(BuildOutputSeverity.Error))] + [InlineData("proj.csproj : warning NU1903: Package has a known vulnerability", nameof(BuildOutputSeverity.Warning))] + [InlineData(" Warning: LocalBranchBuildVersionNumber is null", nameof(BuildOutputSeverity.Warning))] + [InlineData(" 0 Warning(s)", nameof(BuildOutputSeverity.Info))] + [InlineData(" 0 Error(s)", nameof(BuildOutputSeverity.Info))] + [InlineData("Build succeeded.", nameof(BuildOutputSeverity.Info))] + [InlineData(" Solution: bin\\Debug\\net462\\Sln.zip packed successfully", nameof(BuildOutputSeverity.Info))] + public void Classify_ReturnsExpectedSeverity(string line, string expected) + { + Assert.Equal(Enum.Parse(expected), SolutionBuildOutput.Classify(line)); + } + + [Fact] + public void FindZips_ReturnsEmpty_WhenDirectoryMissing() + { + var (fresh, all) = SolutionBuildOutput.FindSolutionZips(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DateTime.UtcNow); + Assert.Empty(fresh); + Assert.Empty(all); + } + + [Fact] + public void FindZips_FindsZipInAnyTfmSubfolder() + { + using var dir = new TempDir(); + var zip = dir.CreateFile(Path.Combine("net472", "Sln.zip")); + + var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1)); + + Assert.Equal([zip], fresh); + Assert.Equal([zip], all); + } + + [Fact] + public void FindZips_ExcludesStaleZipsFromFresh() + { + using var dir = new TempDir(); + var stale = dir.CreateFile(Path.Combine("net462", "Old.zip")); + File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-2)); + + var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1)); + + Assert.Empty(fresh); + Assert.Equal([stale], all); + } + + [Fact] + public void FindZips_OrdersFreshNewestFirst() + { + using var dir = new TempDir(); + var older = dir.CreateFile(Path.Combine("net462", "Older.zip")); + var newer = dir.CreateFile(Path.Combine("net472", "Newer.zip")); + File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddSeconds(-30)); + File.SetLastWriteTimeUtc(newer, DateTime.UtcNow); + + var (fresh, _) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1)); + + Assert.Equal([newer, older], fresh); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "txc-tests-" + Guid.NewGuid().ToString("N")); + + public TempDir() => Directory.CreateDirectory(Path); + + public string CreateFile(string relativePath) + { + var fullPath = System.IO.Path.Combine(Path, relativePath); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!); + File.WriteAllBytes(fullPath, [0x50, 0x4B]); + return fullPath; + } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +}