diff --git a/TrxLib.Tests/TrxParserRegressionTests.cs b/TrxLib.Tests/TrxParserRegressionTests.cs new file mode 100644 index 0000000..a5b8220 --- /dev/null +++ b/TrxLib.Tests/TrxParserRegressionTests.cs @@ -0,0 +1,94 @@ +using AwesomeAssertions; + +namespace TrxLib.Tests; + +/// +/// Regression tests verifying TRX parsing correctness against the vstest object model. +/// +public class TrxParserRegressionTests +{ + // All vstest outcome strings must round-trip correctly through the parser. + // A null/missing outcome attribute maps to Error: TestOutcome.Error is ordinal 0 + // (the enum default), so vstest omits the attribute rather than writing "Error". + [Theory] + [InlineData("Error", TestOutcome.Error)] + [InlineData("Aborted", TestOutcome.Aborted)] + [InlineData("NotRunnable", TestOutcome.NotRunnable)] + [InlineData(null, TestOutcome.Error)] // absent attribute = Error + public void Parse_OutcomeAttribute_RoundTrips(string? outcomeAttr, TestOutcome expected) + { + using var trxFile = new TempTrxFile(MinimalTrx(outcome: outcomeAttr)); + var results = TrxParser.Parse(trxFile.FileInfo); + results.Single().Outcome.Should().Be(expected); + } + + // TestProjectDirectory must resolve to the project root for all standard .NET SDK + // output layouts, including RID-qualified and publish subdirectories nested under bin/. + public static TheoryData DirectoryLayouts => new() + { + { "fake_project_rid", ["bin", "Debug", "net8.0", "win-x64", "MyProject.dll"] }, + { "fake_project_publish", ["bin", "Release", "net8.0", "publish", "MyProject.dll"] }, + { "fake_project_rid_publish", ["bin", "Release", "net8.0", "linux-x64", "publish", "MyProject.dll"] }, + }; + + [Theory, MemberData(nameof(DirectoryLayouts))] + public void Parse_TestProjectDirectory_ResolvesFromBinAnchor(string subfolder, string[] segments) + { + var projectRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), subfolder)); + var codebase = Path.Combine(new[] { projectRoot }.Concat(segments).ToArray()); + using var trxFile = new TempTrxFile(MinimalTrx(codeBase: codebase)); + var results = TrxParser.Parse(trxFile.FileInfo); + results.Single().TestProjectDirectory!.FullName.TrimEnd(Path.DirectorySeparatorChar) + .Should().Be(projectRoot.TrimEnd(Path.DirectorySeparatorChar)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static string MinimalTrx(string? outcome = null, string codeBase = "test.dll") + { + const string testId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + const string execId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + var codebaseXml = codeBase.Replace('\\', '/'); + return $""" + + + + + + + + + + + + """; + } + + /// Helper that writes a TRX string to a temp file and deletes it on dispose. + private sealed class TempTrxFile : IDisposable + { + public FileInfo FileInfo { get; } + + public TempTrxFile(string content) + { + var path = Path.Combine(Path.GetTempPath(), $"trxtest_{Guid.NewGuid():N}.trx"); + File.WriteAllText(path, content); + FileInfo = new FileInfo(path); + } + + public void Dispose() => FileInfo.Delete(); + } +} diff --git a/TrxLib/TestOutcome.cs b/TrxLib/TestOutcome.cs index a2c2729..4c279c7 100644 --- a/TrxLib/TestOutcome.cs +++ b/TrxLib/TestOutcome.cs @@ -81,5 +81,5 @@ public enum TestOutcome /// /// Test is in the execution queue, was not started yet. /// - Pending + Pending, } diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 1bba8bb..08f5fbe 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -18,22 +18,22 @@ public static TestResultSet Parse(FileInfo trxFile) { using var stream = trxFile.OpenRead(); TestRun? testRun = DeserializeTestRun(stream); - if (testRun == null) + if (testRun is null) return new TestResultSet(); // Build a lookup for UnitTest definitions - var testDefinitions = testRun.TestDefinitions?.UnitTests?.Where(u => u.Id != null).ToDictionary(u => u.Id!) ?? new(); + var testDefinitions = testRun.TestDefinitions?.UnitTests?.Where(u => u.Id is not null).ToDictionary(u => u.Id!) ?? new(); var results = new List(); foreach (var result in testRun.Results?.UnitTestResults ?? Enumerable.Empty()) { // Find the test definition UnitTest? unitTest = null; - if (result.TestId != null) + if (result.TestId is not null) testDefinitions.TryGetValue(result.TestId, out unitTest); var testMethod = unitTest?.TestMethod; - var testMethodDomain = testMethod == null ? null : new TestMethod + var testMethodDomain = testMethod is null ? null : new TestMethod { CodeBase = testMethod.CodeBase ?? string.Empty, ClassName = testMethod.ClassName ?? string.Empty, @@ -43,13 +43,25 @@ public static TestResultSet Parse(FileInfo trxFile) var outcome = result.Outcome?.ToLowerInvariant() switch { - "passed" => TestOutcome.Passed, - "failed" => TestOutcome.Failed, - "notexecuted" => TestOutcome.NotExecuted, - "inconclusive" => TestOutcome.Inconclusive, - "timeout" => TestOutcome.Timeout, - "pending" => TestOutcome.Pending, - _ => TestOutcome.NotExecuted + "passed" => TestOutcome.Passed, + "failed" => TestOutcome.Failed, + "notexecuted" => TestOutcome.NotExecuted, + "inconclusive" => TestOutcome.Inconclusive, + "timeout" => TestOutcome.Timeout, + "pending" => TestOutcome.Pending, + "error" => TestOutcome.Error, + "aborted" => TestOutcome.Aborted, + "notrunnable" => TestOutcome.NotRunnable, + "disconnected" => TestOutcome.Disconnected, + "warning" => TestOutcome.Warning, + "completed" => TestOutcome.Completed, + "inprogress" => TestOutcome.InProgress, + "passedbutrunaborted" => TestOutcome.PassedButRunAborted, + // A null/absent outcome attribute means Error in vstest's serialization: + // TestOutcome.Error is ordinal 0 (the enum default), so XmlPersistence + // omits the attribute when outcome == Error. + null => TestOutcome.Error, + _ => TestOutcome.NotExecuted }; DateTimeOffset? resultStartTime = null, resultEndTime = null; @@ -63,7 +75,7 @@ public static TestResultSet Parse(FileInfo trxFile) // Build the fully qualified test name correctly string fullyQualifiedTestName = string.Empty; - if (testMethodDomain != null) + if (testMethodDomain is not null) { if (!string.IsNullOrEmpty(testMethodDomain.ClassName) && !string.IsNullOrEmpty(testMethodDomain.Name)) { @@ -86,7 +98,7 @@ public static TestResultSet Parse(FileInfo trxFile) else if (result.TestName.StartsWith(methodShortName, StringComparison.Ordinal)) candidate = result.TestName.Substring(methodShortName.Length); - if (candidate?.StartsWith("(", StringComparison.Ordinal) == true) + if (candidate?.StartsWith("(", StringComparison.Ordinal) is true) paramSuffix = candidate; } fullyQualifiedTestName = baseFqtn + paramSuffix; @@ -108,17 +120,24 @@ public static TestResultSet Parse(FileInfo trxFile) codebaseFile = new FileInfo(testMethodDomain.CodeBase); } - // Calculate test project directory from codebase if available + // Calculate test project directory from codebase if available. + // Walk up to the 'bin' folder, then take its parent — this handles + // all standard .NET SDK output layouts: + // bin/{config}/{tfm}/ (depth 3 — classic) + // bin/{config}/{tfm}/{rid}/ (depth 4 — self-contained) + // bin/{config}/{tfm}/publish/ (depth 4 — publish output) + // bin/{config}/{tfm}/{rid}/publish/ (depth 5 — self-contained publish) DirectoryInfo? testProjectDirectory = null; - if (codebaseFile != null && codebaseFile.Directory != null) + if (codebaseFile?.Directory is { } dir) { - // Go up 3 levels to get the project directory (bin/Debug/netcoreappX.X/) - var dir = codebaseFile.Directory; - for (int i = 0; i < 3 && dir?.Parent != null; i++) + while (dir.Parent is not null && !string.Equals(dir.Name, "bin", StringComparison.OrdinalIgnoreCase)) { dir = dir.Parent; } - testProjectDirectory = dir; + // Return the parent of the 'bin' folder (the project root). + // If no 'bin' folder was found, leave testProjectDirectory as null. + if (string.Equals(dir.Name, "bin", StringComparison.OrdinalIgnoreCase)) + testProjectDirectory = dir.Parent; } // Extract StdOut if available @@ -161,14 +180,14 @@ public static TestResultSet Parse(FileInfo trxFile) }; // Set timing properties from the Times element if available - if (creationTime.HasValue) - testResultSet.CreatedTime = creationTime.Value; - if (queueingTime.HasValue) - testResultSet.QueuedTime = queueingTime.Value; - if (startTime.HasValue) - testResultSet.StartedTime = startTime.Value; - if (finishTime.HasValue) - testResultSet.CompletedTime = finishTime.Value; + if (creationTime is { } created) + testResultSet.CreatedTime = created; + if (queueingTime is { } queued) + testResultSet.QueuedTime = queued; + if (startTime is { } started) + testResultSet.StartedTime = started; + if (finishTime is { } finished) + testResultSet.CompletedTime = finished; return testResultSet; } @@ -186,7 +205,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var root = doc.Root; - if (root == null) + if (root is null) return null; var testRun = new TestRun @@ -197,7 +216,7 @@ public static TestResultSet Parse(FileInfo trxFile) }; var timesEl = root.Element(TrxNs + "Times"); - if (timesEl != null) + if (timesEl is not null) { testRun.Times = new Times { @@ -209,7 +228,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var testSettingsEl = root.Element(TrxNs + "TestSettings"); - if (testSettingsEl != null) + if (testSettingsEl is not null) { testRun.TestSettings = new TestSettings { @@ -222,7 +241,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var testDefsEl = root.Element(TrxNs + "TestDefinitions"); - if (testDefsEl != null) + if (testDefsEl is not null) { testRun.TestDefinitions = new TestDefinitions { @@ -237,7 +256,7 @@ public static TestResultSet Parse(FileInfo trxFile) Execution = ut.Element(TrxNs + "Execution") is XElement execEl ? new Execution { Id = (string?)execEl.Attribute("id") } : null, - TestMethod = tmEl != null ? new TestMethod + TestMethod = tmEl is not null ? new TestMethod { CodeBase = (string?)tmEl.Attribute("codeBase"), ClassName = (string?)tmEl.Attribute("className"), @@ -250,7 +269,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var resultsEl = root.Element(TrxNs + "Results"); - if (resultsEl != null) + if (resultsEl is not null) { testRun.Results = new Results { @@ -294,7 +313,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var resultSummaryEl = root.Element(TrxNs + "ResultSummary"); - if (resultSummaryEl != null) + if (resultSummaryEl is not null) { Counters? counters = null; if (resultSummaryEl.Element(TrxNs + "Counters") is XElement countersEl) @@ -336,7 +355,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var testListsEl = root.Element(TrxNs + "TestLists"); - if (testListsEl != null) + if (testListsEl is not null) { testRun.TestLists = new TestLists { @@ -349,7 +368,7 @@ public static TestResultSet Parse(FileInfo trxFile) } var testEntriesEl = root.Element(TrxNs + "TestEntries"); - if (testEntriesEl != null) + if (testEntriesEl is not null) { testRun.TestEntries = new TestEntries {