Skip to content
Merged
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
94 changes: 94 additions & 0 deletions TrxLib.Tests/TrxParserRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using AwesomeAssertions;

namespace TrxLib.Tests;

/// <summary>
/// Regression tests verifying TRX parsing correctness against the vstest object model.
/// </summary>
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<string, string[]> 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 $"""
<?xml version="1.0" encoding="UTF-8"?>
<TestRun id="cccccccc-cccc-cccc-cccc-cccccccccccc" name="test run"
xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
<Results>
<UnitTestResult executionId="{execId}" testId="{testId}"
testName="SomeNamespace.SomeClass.SomeTest" computerName="host"
duration="00:00:00.0010000"
startTime="2024-01-01T00:00:00.0000000Z"
endTime="2024-01-01T00:00:00.0000000Z"
testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b"
{(outcome is null ? "" : $"outcome=\"{outcome}\"")}
testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d"
relativeResultsDirectory="{execId}" />
</Results>
<TestDefinitions>
<UnitTest name="SomeTest" id="{testId}">
<TestMethod className="SomeNamespace.SomeClass" name="SomeTest"
adapterTypeName="executor://mstestadapter/v2"
codeBase="{codebaseXml}" />
</UnitTest>
</TestDefinitions>
</TestRun>
""";
}

/// <summary>Helper that writes a TRX string to a temp file and deletes it on dispose.</summary>
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();
}
}
2 changes: 1 addition & 1 deletion TrxLib/TestOutcome.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ public enum TestOutcome
/// <summary>
/// Test is in the execution queue, was not started yet.
/// </summary>
Pending
Pending,
}
91 changes: 55 additions & 36 deletions TrxLib/TrxParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestResult>();
foreach (var result in testRun.Results?.UnitTestResults ?? Enumerable.Empty<UnitTestResult>())
{
// 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,
Expand All @@ -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;
Expand All @@ -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))
{
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand All @@ -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
{
Expand All @@ -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
{
Expand All @@ -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
{
Expand All @@ -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"),
Expand All @@ -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
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down
Loading