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
{