diff --git a/TrxLib.Tests/TestResultTests.cs b/TrxLib.Tests/TestResultTests.cs index ea7f3e7..0abb220 100644 --- a/TrxLib.Tests/TestResultTests.cs +++ b/TrxLib.Tests/TestResultTests.cs @@ -77,4 +77,36 @@ public void Inferred_properties_are_not_inferred_from_fully_qualified_test_name_ testResult.Namespace.Should().BeNull(); testResult.TestName.Should().Be(fullyQualifiedTestName); } + + [Fact] + public void Theory_test_with_dotted_param_is_parsed_correctly_when_testMethod_provided() + { + // When testMethod is supplied the constructor must use testMethod.ClassName + // directly instead of splitting the FQTN on '.'. Without the fix, a param + // value like "foo.bar" causes ClassName and TestName to be corrupt. + const string className = "System.CommandLine.Tests.ParserTests"; + const string methodName = "Parse_theory_method"; + const string fqtn = $"{className}.{methodName}(param: \"foo.bar\")"; + + var testResult = new TestResult(fqtn, TestOutcome.Passed, + testMethod: new TestMethod { ClassName = className, Name = methodName }); + + using var _ = new AssertionScope(); + testResult.FullyQualifiedTestName.Should().Be(fqtn); + testResult.FullyQualifiedClassName.Should().Be(className); + testResult.ClassName.Should().Be("ParserTests"); + testResult.Namespace.Should().Be("System.CommandLine.Tests"); + testResult.TestName.Should().Be($"{methodName}(param: \"foo.bar\")"); + } + + [Fact] + public void ToString_DoesNotThrow_ForOutcomeValueNotInEnum() + { + // TestResult.ToString() has a _ => throw arm that crashes on any enum value + // not listed in its switch expression (e.g. future additions to TestOutcome, or + // values written by vstest that TrxLib doesn't yet map, such as "Completed"). + var testResult = new TestResult("some.namespace.SomeClass.SomeTest", (TestOutcome)99); + var act = () => testResult.ToString(); + act.Should().NotThrow(); + } } diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index c37ef58..9b56fd8 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using AwesomeAssertions; +using AwesomeAssertions.Execution; namespace TrxLib.Tests; @@ -175,6 +176,117 @@ public void Parse_Example1OSXTrx_ParsesCodebaseCorrectly() test.Codebase?.FullName.Should().Be(@"/Users/josequ/dev/cli/test/Microsoft.DotNet.Cli.Utils.Tests/bin/Debug/netcoreapp1.0/Microsoft.DotNet.Cli.Utils.Tests.dll"); } + // ── Parser regression tests ────────────────────────────────────────────────── + // These cover attributes/elements present in sample TRX files that were + // previously silently discarded. All tests below should be GREEN. + + [Fact] + public void Parse_OneTestFailureTrx_PopulatesTestRunId() + { + // TestResultSet.TestRunId is never assigned; parser must set it from TestRun.Id. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.TestRunId.Should().Be("a1293f2f-ba2d-4aa4-91a7-ceed97fd4735"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesRunUser() + { + // runUser attribute on is present in every TRX file but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + results.OriginalTestRun!.RunUser.Should().Be("BenjaminMichaelis"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesResultSummaryOutcome() + { + // is present in every TRX file but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + results.OriginalTestRun!.ResultSummary.Should().NotBeNull(); + results.OriginalTestRun!.ResultSummary!.Outcome.Should().Be("Failed"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesCountersTotals() + { + // inside but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + var counters = results.OriginalTestRun!.ResultSummary?.Counters; + counters.Should().NotBeNull(); + using var _ = new AssertionScope(); + counters!.Total.Should().Be(19); + counters.Passed.Should().Be(18); + counters.Failed.Should().Be(1); + counters.Executed.Should().Be(19); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesTestLists() + { + // always has two default entries but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + var testLists = results.OriginalTestRun!.TestLists?.Items; + testLists.Should().NotBeNull(); + testLists.Should().HaveCount(2); + testLists.Should().ContainSingle(l => l.Name == "Results Not in a List" && l.Id == "8c84fa94-04c1-424b-9868-57a2d4851a1d"); + testLists.Should().ContainSingle(l => l.Name == "All Loaded Results" && l.Id == "19431567-8539-422a-85d7-44ee4e166bda"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesTestEntries() + { + // is present in every TRX file but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + var entries = results.OriginalTestRun!.TestEntries?.Items; + entries.Should().NotBeNull(); + entries.Should().HaveCount(19); + // Spot-check one entry links testId -> executionId -> testListId correctly. + entries.Should().ContainSingle(e => + e.TestId == "35e9c03c-7c18-2ee8-7216-91e69cfe406e" && + e.ExecutionId == "9682c228-090a-47f3-96d2-26ffa23c9a53" && + e.TestListId == "8c84fa94-04c1-424b-9868-57a2d4851a1d"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesUnitTestResultExecutionId() + { + // executionId attribute on is present in every TRX but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + results.OriginalTestRun.Should().NotBeNull(); + var rawResults = results.OriginalTestRun!.Results?.UnitTestResults; + rawResults.Should().NotBeNull(); + rawResults.Should().ContainSingle(r => r.ExecutionId == "9682c228-090a-47f3-96d2-26ffa23c9a53"); + } + + [Fact] + public void Parse_OneTestFailureTrx_ParsesUnitTestDefinitionAttributes() + { + // Both storage and are on but currently not parsed. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("OneTestFailure.trx"))); + var unitTests = results.OriginalTestRun?.TestDefinitions?.UnitTests; + unitTests.Should().NotBeNull(); + using var _ = new AssertionScope(); + unitTests.Should().Contain(u => u.Storage == @"essentialcsharp\src\chapter01.tests\bin\debug\net6.0\chapter01.tests.dll"); + unitTests.Should().Contain(u => u.Execution != null && u.Execution.Id == "86ff1fe0-ef46-4281-b70a-7ecc3ed2376b"); + } + + [Fact] + public void Parse_ComplexTrx_FullyQualifiedTestNameIncludesTheoryParameters() + { + // The parser builds FQTN from ClassName.Name, discarding the (params) suffix from testName. + // For theory tests every invocation gets the same FQTN, making distinct runs indistinguishable. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("complex.trx"))); + const string expectedFqtn = + "System.CommandLine.Tests.ParserTests+MultiplePositions" + + ".When_an_option_is_shared_between_an_outer_and_inner_command_then_specifying_in_one_does_not_result_in_error_on_other" + + "(commandLine: \"outer --the-option xyz inner\")"; + results.Select(r => r.FullyQualifiedTestName).Should().Contain(expectedFqtn); + } + [Fact] public void Parse_Example2WindowsTrx_TestsDoNotAppearWithMoreThanOneOutcome() { diff --git a/TrxLib/Counters.cs b/TrxLib/Counters.cs new file mode 100644 index 0000000..fe023da --- /dev/null +++ b/TrxLib/Counters.cs @@ -0,0 +1,74 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents the test result counters from the ResultSummary element of a TRX file. +/// Contains the authoritative vstest-computed counts for each outcome category. +/// +public class Counters +{ + /// Gets or sets the total number of tests. + [XmlAttribute("total")] + public int Total { get; set; } + + /// Gets or sets the number of tests that were executed. + [XmlAttribute("executed")] + public int Executed { get; set; } + + /// Gets or sets the number of tests that passed. + [XmlAttribute("passed")] + public int Passed { get; set; } + + /// Gets or sets the number of tests that failed. + [XmlAttribute("failed")] + public int Failed { get; set; } + + /// Gets or sets the number of tests that encountered a system error. + [XmlAttribute("error")] + public int Error { get; set; } + + /// Gets or sets the number of tests that timed out. + [XmlAttribute("timeout")] + public int Timeout { get; set; } + + /// Gets or sets the number of tests that were aborted. + [XmlAttribute("aborted")] + public int Aborted { get; set; } + + /// Gets or sets the number of tests with inconclusive results. + [XmlAttribute("inconclusive")] + public int Inconclusive { get; set; } + + /// Gets or sets the number of tests that passed but the run was aborted. + [XmlAttribute("passedButRunAborted")] + public int PassedButRunAborted { get; set; } + + /// Gets or sets the number of tests that were not runnable. + [XmlAttribute("notRunnable")] + public int NotRunnable { get; set; } + + /// Gets or sets the number of tests that were not executed. + [XmlAttribute("notExecuted")] + public int NotExecuted { get; set; } + + /// Gets or sets the number of tests that were disconnected. + [XmlAttribute("disconnected")] + public int Disconnected { get; set; } + + /// Gets or sets the number of tests with a warning outcome. + [XmlAttribute("warning")] + public int Warning { get; set; } + + /// Gets or sets the number of completed tests. + [XmlAttribute("completed")] + public int Completed { get; set; } + + /// Gets or sets the number of tests currently in progress. + [XmlAttribute("inProgress")] + public int InProgress { get; set; } + + /// Gets or sets the number of tests that are pending. + [XmlAttribute("pending")] + public int Pending { get; set; } +} diff --git a/TrxLib/Execution.cs b/TrxLib/Execution.cs new file mode 100644 index 0000000..c1bdd4b --- /dev/null +++ b/TrxLib/Execution.cs @@ -0,0 +1,17 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents the execution element of a unit test definition in a TRX file. +/// Contains the execution ID that links a test definition to its result. +/// +public class Execution +{ + /// + /// Gets or sets the execution identifier. Links this test definition to the + /// corresponding UnitTestResult via its executionId attribute. + /// + [XmlAttribute("id")] + public string? Id { get; set; } +} diff --git a/TrxLib/ResultSummary.cs b/TrxLib/ResultSummary.cs new file mode 100644 index 0000000..91f1259 --- /dev/null +++ b/TrxLib/ResultSummary.cs @@ -0,0 +1,28 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents the ResultSummary element of a TRX file. +/// Contains the overall run outcome, authoritative vstest-computed counters, and run-level output. +/// +public class ResultSummary +{ + /// + /// Gets or sets the overall outcome of the test run (e.g. "Passed", "Failed", "Completed"). + /// + [XmlAttribute("outcome")] + public string? Outcome { get; set; } + + /// + /// Gets or sets the test result counters for the run. + /// + [XmlElement("Counters")] + public Counters? Counters { get; set; } + + /// + /// Gets or sets the run-level output (e.g. run-level stdout written by the test host). + /// + [XmlElement("Output")] + public Output? Output { get; set; } +} diff --git a/TrxLib/TestEntries.cs b/TrxLib/TestEntries.cs new file mode 100644 index 0000000..b3cda4e --- /dev/null +++ b/TrxLib/TestEntries.cs @@ -0,0 +1,14 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents the TestEntries element of a TRX file. +/// Contains the execution index linking test IDs to execution IDs and test list categories. +/// +public class TestEntries +{ + /// Gets or sets the individual test entry records. + [XmlElement("TestEntry")] + public List? Items { get; set; } +} diff --git a/TrxLib/TestEntry.cs b/TrxLib/TestEntry.cs new file mode 100644 index 0000000..584f8ec --- /dev/null +++ b/TrxLib/TestEntry.cs @@ -0,0 +1,22 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents a single TestEntry in the TestEntries section of a TRX file. +/// Links a test definition (testId) to its execution record (executionId) and list category (testListId). +/// +public class TestEntry +{ + /// Gets or sets the test definition identifier. + [XmlAttribute("testId")] + public string? TestId { get; set; } + + /// Gets or sets the execution identifier. + [XmlAttribute("executionId")] + public string? ExecutionId { get; set; } + + /// Gets or sets the test list identifier. + [XmlAttribute("testListId")] + public string? TestListId { get; set; } +} diff --git a/TrxLib/TestList.cs b/TrxLib/TestList.cs new file mode 100644 index 0000000..0e2c2b1 --- /dev/null +++ b/TrxLib/TestList.cs @@ -0,0 +1,18 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents a single TestList entry in the TestLists section of a TRX file. +/// vstest always writes two default lists: "Results Not in a List" and "All Loaded Results". +/// +public class TestList +{ + /// Gets or sets the display name of the test list. + [XmlAttribute("name")] + public string? Name { get; set; } + + /// Gets or sets the unique identifier of the test list. + [XmlAttribute("id")] + public string? Id { get; set; } +} diff --git a/TrxLib/TestLists.cs b/TrxLib/TestLists.cs new file mode 100644 index 0000000..7363b2f --- /dev/null +++ b/TrxLib/TestLists.cs @@ -0,0 +1,14 @@ +using System.Xml.Serialization; + +namespace TrxLib; + +/// +/// Represents the TestLists element of a TRX file. +/// Contains the list categories used to group test results. +/// +public class TestLists +{ + /// Gets or sets the individual test list entries. + [XmlElement("TestList")] + public List? Items { get; set; } +} diff --git a/TrxLib/TestResult.cs b/TrxLib/TestResult.cs index 3a14ce7..1929452 100644 --- a/TrxLib/TestResult.cs +++ b/TrxLib/TestResult.cs @@ -35,29 +35,55 @@ public TestResult( StdOut = stdOut; TestMethod = testMethod; - var testNameParts = fullyQualifiedTestName.Split('.'); - - if (testNameParts.Length > 1) + if (testMethod != null && !string.IsNullOrEmpty(testMethod.ClassName)) { - var testName = testNameParts[^1]; - var className = testNameParts[^2]; - var fullyQualifiedClassName = string.Join(".", testNameParts.Take(testNameParts.Length - 1)); - var @namespace = string.Join(".", testNameParts.Take(testNameParts.Length - 2)); - - // only infer these if fullyQualifiedTestName is typical of .NET tests - if (!fullyQualifiedClassName.Contains(" ")) + // Use the authoritative TRX TestMethod.className field directly. + // Splitting the FQTN on '.' is unreliable when theory-test parameter + // values contain dots (e.g. "(param: \"foo.bar\")"). + FullyQualifiedClassName = testMethod.ClassName; + var lastDot = testMethod.ClassName.LastIndexOf('.'); + if (lastDot >= 0) + { + ClassName = testMethod.ClassName[(lastDot + 1)..]; + Namespace = testMethod.ClassName[..lastDot]; + } + else { - TestName = testName; - ClassName = className; - FullyQualifiedClassName = fullyQualifiedClassName; - Namespace = @namespace; + ClassName = testMethod.ClassName; } + // TestName = everything after "FullyQualifiedClassName." in the FQTN, + // which preserves the theory-test parameter suffix intact. + var prefix = testMethod.ClassName + "."; + TestName = fullyQualifiedTestName.StartsWith(prefix, StringComparison.Ordinal) + ? fullyQualifiedTestName[prefix.Length..] + : (testMethod.Name ?? fullyQualifiedTestName); } - - if (string.IsNullOrEmpty(TestName)) + else { - TestName = FullyQualifiedTestName; + // Fallback: derive from dot-splitting the FQTN. + // Works for standard dotted names; breaks only when parameter values + // contain dots and no testMethod metadata is available. + var testNameParts = fullyQualifiedTestName.Split('.'); + + if (testNameParts.Length > 1) + { + var fullyQualifiedClassName = string.Join(".", testNameParts.Take(testNameParts.Length - 1)); + + // only infer these if fullyQualifiedTestName is typical of .NET tests + if (!fullyQualifiedClassName.Contains(" ")) + { + TestName = testNameParts[^1]; + ClassName = testNameParts[^2]; + FullyQualifiedClassName = fullyQualifiedClassName; + Namespace = string.Join(".", testNameParts.Take(testNameParts.Length - 2)); + } + } + + if (string.IsNullOrEmpty(TestName)) + { + TestName = FullyQualifiedTestName; + } } } @@ -146,7 +172,7 @@ public override string ToString() TestOutcome.Inconclusive => "⚠️", TestOutcome.Timeout => "⌚", TestOutcome.Pending => "⏳", - _ => throw new ArgumentOutOfRangeException() + _ => "❓" }; return $"{badge} {FullyQualifiedTestName}"; diff --git a/TrxLib/TestRun.cs b/TrxLib/TestRun.cs index 4790e8e..a86241d 100644 --- a/TrxLib/TestRun.cs +++ b/TrxLib/TestRun.cs @@ -44,4 +44,29 @@ public class TestRun /// [XmlElement("TestSettings")] public TestSettings? TestSettings { get; set; } + + /// + /// Gets or sets the user account that initiated the test run. + /// + [XmlAttribute("runUser")] + public string? RunUser { get; set; } + + /// + /// Gets or sets the result summary for the test run, including the overall outcome + /// and authoritative vstest-computed counters. + /// + [XmlElement("ResultSummary")] + public ResultSummary? ResultSummary { get; set; } + + /// + /// Gets or sets the test list categories used to group results. + /// + [XmlElement("TestLists")] + public TestLists? TestLists { get; set; } + + /// + /// Gets or sets the test entries index linking test IDs to execution IDs. + /// + [XmlElement("TestEntries")] + public TestEntries? TestEntries { get; set; } } diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index d471820..4a66e9d 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -66,11 +66,19 @@ public static TestResultSet Parse(FileInfo trxFile) { if (!string.IsNullOrEmpty(testMethodDomain.ClassName) && !string.IsNullOrEmpty(testMethodDomain.Name)) { - // If Name already starts with ClassName, use Name as-is - if (testMethodDomain.Name.StartsWith(testMethodDomain.ClassName + ".")) - fullyQualifiedTestName = testMethodDomain.Name; + // If Name already contains the full FQTN (starts with ClassName), use Name as-is + // as the base; otherwise build it from ClassName.Name. + var baseFqtn = testMethodDomain.Name.StartsWith(testMethodDomain.ClassName + ".", StringComparison.Ordinal) + ? testMethodDomain.Name + : $"{testMethodDomain.ClassName}.{testMethodDomain.Name}"; + + // Preserve theory-test parameter suffixes: if testName starts with the base + // FQTN and has additional content (e.g. "(param: value)"), use testName + // directly so each parameterized invocation has a unique FQTN. + if (result.TestName != null && result.TestName.StartsWith(baseFqtn, StringComparison.Ordinal)) + fullyQualifiedTestName = result.TestName; else - fullyQualifiedTestName = $"{testMethodDomain.ClassName}.{testMethodDomain.Name}"; + fullyQualifiedTestName = baseFqtn; } else { @@ -134,6 +142,7 @@ public static TestResultSet Parse(FileInfo trxFile) var testResultSet = new TestResultSet(results) { TestRunName = testRun.Name ?? string.Empty, + TestRunId = testRun.Id ?? string.Empty, TestFilePath = trxFile.FullName, OriginalTestRun = testRun }; diff --git a/TrxLib/UnitTest.cs b/TrxLib/UnitTest.cs index eb4b232..ad7c4de 100644 --- a/TrxLib/UnitTest.cs +++ b/TrxLib/UnitTest.cs @@ -27,4 +27,16 @@ public class UnitTest /// [XmlElement("TestMethod")] public TestMethod? TestMethod { get; set; } + + /// + /// Gets or sets the path to the test assembly. Stored as a lowercased path by vstest. + /// + [XmlAttribute("storage")] + public string? Storage { get; set; } + + /// + /// Gets or sets the execution element containing the execution ID for this test definition. + /// + [XmlElement("Execution")] + public Execution? Execution { get; set; } } diff --git a/TrxLib/UnitTestResult.cs b/TrxLib/UnitTestResult.cs index fa31461..05b3a9e 100644 --- a/TrxLib/UnitTestResult.cs +++ b/TrxLib/UnitTestResult.cs @@ -57,4 +57,28 @@ public class UnitTestResult /// [XmlElement("Output")] public Output? Output { get; set; } + + /// + /// Gets or sets the execution identifier. Links this result to its TestEntry and UnitTest/Execution records. + /// + [XmlAttribute("executionId")] + public string? ExecutionId { get; set; } + + /// + /// Gets or sets the test list identifier. References a TestList in the TestLists section. + /// + [XmlAttribute("testListId")] + public string? TestListId { get; set; } + + /// + /// Gets or sets the test type GUID identifying the kind of test (e.g. unit test adapter GUID). + /// + [XmlAttribute("testType")] + public string? TestType { get; set; } + + /// + /// Gets or sets the relative results directory for this test result's attachments. + /// + [XmlAttribute("relativeResultsDirectory")] + public string? RelativeResultsDirectory { get; set; } }