From 452389f278a0d675c4348c165591179eacc5def6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:28:36 -0700 Subject: [PATCH 1/4] test: add failing tests for parser data-loss bugs (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stub model classes and 10 failing parser tests plus 1 unit test that cover bugs confirmed against existing sample TRX files: Model additions (no parser wiring yet — tests intentionally RED): - Execution, Counters, ResultSummary, TestList, TestLists, TestEntry, TestEntries stub classes - TestRun: RunUser, ResultSummary, TestLists, TestEntries properties - UnitTestResult: ExecutionId, TestListId, TestType, RelativeResultsDirectory properties - UnitTest: Storage, Execution properties Failing tests (red until parser is fixed): - TestRunId never populated from TestRun.Id - runUser attribute on silently discarded - element never parsed - inside ResultSummary never parsed - element never parsed - element never parsed - executionId attribute on discarded - storage + on discarded (single combined test) - Theory test parameter suffix dropped from FQTN - TestResult.ToString() throws ArgumentOutOfRangeException for unknown/future TestOutcome enum values --- TrxLib.Tests/TestResultTests.cs | 11 ++++ TrxLib.Tests/TrxParserTests.cs | 112 ++++++++++++++++++++++++++++++++ TrxLib/Counters.cs | 56 ++++++++++++++++ TrxLib/Execution.cs | 14 ++++ TrxLib/ResultSummary.cs | 23 +++++++ TrxLib/TestEntries.cs | 11 ++++ TrxLib/TestEntry.cs | 17 +++++ TrxLib/TestList.cs | 14 ++++ TrxLib/TestLists.cs | 11 ++++ 9 files changed, 269 insertions(+) create mode 100644 TrxLib/Counters.cs create mode 100644 TrxLib/Execution.cs create mode 100644 TrxLib/ResultSummary.cs create mode 100644 TrxLib/TestEntries.cs create mode 100644 TrxLib/TestEntry.cs create mode 100644 TrxLib/TestList.cs create mode 100644 TrxLib/TestLists.cs diff --git a/TrxLib.Tests/TestResultTests.cs b/TrxLib.Tests/TestResultTests.cs index ea7f3e7..960d301 100644 --- a/TrxLib.Tests/TestResultTests.cs +++ b/TrxLib.Tests/TestResultTests.cs @@ -77,4 +77,15 @@ public void Inferred_properties_are_not_inferred_from_fully_qualified_test_name_ testResult.Namespace.Should().BeNull(); testResult.TestName.Should().Be(fullyQualifiedTestName); } + + [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..594cc75 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"); } + // ── Failing tests (TDD) ───────────────────────────────────────────────────── + // These cover attributes/elements present in sample TRX files that TrxParser + // currently silently discards. Each test should be RED until the parser is fixed. + + [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..123728b --- /dev/null +++ b/TrxLib/Counters.cs @@ -0,0 +1,56 @@ +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. + public int Total { get; set; } + + /// Gets or sets the number of tests that were executed. + public int Executed { get; set; } + + /// Gets or sets the number of tests that passed. + public int Passed { get; set; } + + /// Gets or sets the number of tests that failed. + public int Failed { get; set; } + + /// Gets or sets the number of tests that encountered a system error. + public int Error { get; set; } + + /// Gets or sets the number of tests that timed out. + public int Timeout { get; set; } + + /// Gets or sets the number of tests that were aborted. + public int Aborted { get; set; } + + /// Gets or sets the number of tests with inconclusive results. + public int Inconclusive { get; set; } + + /// Gets or sets the number of tests that passed but the run was aborted. + public int PassedButRunAborted { get; set; } + + /// Gets or sets the number of tests that were not runnable. + public int NotRunnable { get; set; } + + /// Gets or sets the number of tests that were not executed. + public int NotExecuted { get; set; } + + /// Gets or sets the number of tests that were disconnected. + public int Disconnected { get; set; } + + /// Gets or sets the number of tests with a warning outcome. + public int Warning { get; set; } + + /// Gets or sets the number of completed tests. + public int Completed { get; set; } + + /// Gets or sets the number of tests currently in progress. + public int InProgress { get; set; } + + /// Gets or sets the number of tests that are pending. + public int Pending { get; set; } +} diff --git a/TrxLib/Execution.cs b/TrxLib/Execution.cs new file mode 100644 index 0000000..326e3be --- /dev/null +++ b/TrxLib/Execution.cs @@ -0,0 +1,14 @@ +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. + /// + public string? Id { get; set; } +} diff --git a/TrxLib/ResultSummary.cs b/TrxLib/ResultSummary.cs new file mode 100644 index 0000000..ded411e --- /dev/null +++ b/TrxLib/ResultSummary.cs @@ -0,0 +1,23 @@ +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"). + /// + public string? Outcome { get; set; } + + /// + /// Gets or sets the test result counters for the run. + /// + public Counters? Counters { get; set; } + + /// + /// Gets or sets the run-level output (e.g. run-level stdout written by the test host). + /// + public Output? Output { get; set; } +} diff --git a/TrxLib/TestEntries.cs b/TrxLib/TestEntries.cs new file mode 100644 index 0000000..7f2792e --- /dev/null +++ b/TrxLib/TestEntries.cs @@ -0,0 +1,11 @@ +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. + public List? Items { get; set; } +} diff --git a/TrxLib/TestEntry.cs b/TrxLib/TestEntry.cs new file mode 100644 index 0000000..2733234 --- /dev/null +++ b/TrxLib/TestEntry.cs @@ -0,0 +1,17 @@ +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. + public string? TestId { get; set; } + + /// Gets or sets the execution identifier. + public string? ExecutionId { get; set; } + + /// Gets or sets the test list identifier. + public string? TestListId { get; set; } +} diff --git a/TrxLib/TestList.cs b/TrxLib/TestList.cs new file mode 100644 index 0000000..aad234f --- /dev/null +++ b/TrxLib/TestList.cs @@ -0,0 +1,14 @@ +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. + public string? Name { get; set; } + + /// Gets or sets the unique identifier of the test list. + public string? Id { get; set; } +} diff --git a/TrxLib/TestLists.cs b/TrxLib/TestLists.cs new file mode 100644 index 0000000..48f2a5c --- /dev/null +++ b/TrxLib/TestLists.cs @@ -0,0 +1,11 @@ +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. + public List? Items { get; set; } +} From 4e774a161f444d52303f9d302579ebbeb9d51741 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:38:22 -0700 Subject: [PATCH 2/4] fix: add XML annotations and parser fixes to make 10 failing tests pass - Add [XmlAttribute]/[XmlElement] to all new model classes: Counters, Execution, ResultSummary, TestList, TestLists, TestEntry, TestEntries - Wire up new properties on TestRun (runUser, ResultSummary, TestLists, TestEntries), UnitTestResult (executionId, testListId, testType, relativeResultsDirectory), UnitTest (storage, Execution) - Set TestResultSet.TestRunId from testRun.Id in TrxParser - Fix FQTN logic to preserve theory-test parameter suffixes while still correctly handling the case where Name already contains the full FQTN - Fix TestResult.ToString() to return unicode fallback instead of throwing ArgumentOutOfRangeException for unknown outcome values All 48 tests pass (0 regressions). --- TrxLib/Counters.cs | 18 ++++++++++++++++++ TrxLib/Execution.cs | 3 +++ TrxLib/ResultSummary.cs | 5 +++++ TrxLib/TestEntries.cs | 3 +++ TrxLib/TestEntry.cs | 5 +++++ TrxLib/TestList.cs | 4 ++++ TrxLib/TestLists.cs | 3 +++ TrxLib/TestResult.cs | 2 +- TrxLib/TestRun.cs | 25 +++++++++++++++++++++++++ TrxLib/TrxParser.cs | 17 +++++++++++++---- TrxLib/UnitTest.cs | 12 ++++++++++++ TrxLib/UnitTestResult.cs | 24 ++++++++++++++++++++++++ 12 files changed, 116 insertions(+), 5 deletions(-) diff --git a/TrxLib/Counters.cs b/TrxLib/Counters.cs index 123728b..fe023da 100644 --- a/TrxLib/Counters.cs +++ b/TrxLib/Counters.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -7,50 +9,66 @@ namespace TrxLib; 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 index 326e3be..c1bdd4b 100644 --- a/TrxLib/Execution.cs +++ b/TrxLib/Execution.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -10,5 +12,6 @@ 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 index ded411e..91f1259 100644 --- a/TrxLib/ResultSummary.cs +++ b/TrxLib/ResultSummary.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -9,15 +11,18 @@ 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 index 7f2792e..b3cda4e 100644 --- a/TrxLib/TestEntries.cs +++ b/TrxLib/TestEntries.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -7,5 +9,6 @@ namespace TrxLib; 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 index 2733234..584f8ec 100644 --- a/TrxLib/TestEntry.cs +++ b/TrxLib/TestEntry.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -7,11 +9,14 @@ namespace TrxLib; 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 index aad234f..0e2c2b1 100644 --- a/TrxLib/TestList.cs +++ b/TrxLib/TestList.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -7,8 +9,10 @@ namespace TrxLib; 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 index 48f2a5c..7363b2f 100644 --- a/TrxLib/TestLists.cs +++ b/TrxLib/TestLists.cs @@ -1,3 +1,5 @@ +using System.Xml.Serialization; + namespace TrxLib; /// @@ -7,5 +9,6 @@ namespace TrxLib; 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..eacea11 100644 --- a/TrxLib/TestResult.cs +++ b/TrxLib/TestResult.cs @@ -146,7 +146,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..c3b904f 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 + ".") + ? 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)) + 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; } } From cd6ae7678dc4c4b6bb59bfadec631487c0239f06 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:52:18 -0700 Subject: [PATCH 3/4] fix: use TestMethod.ClassName to derive class/namespace instead of dot-splitting FQTN Per the TRX spec, TestMethod.className and TestMethod.name are authoritative fields for the class and method name. Splitting the FQTN on '.' is unreliable when theory-test parameter values contain dots (e.g. (param: "foo.bar")). When testMethod is available, derive FullyQualifiedClassName, ClassName, and Namespace directly from testMethod.ClassName (one LastIndexOf call, no ambiguity). TestName is then the FQTN with the ClassName prefix stripped, which preserves the full parameter suffix for theory tests. The dot-split heuristic is kept as a fallback for when testMethod is not supplied (e.g. direct construction in unit tests or non-TRX callers). Adds a failing-then-passing test: Theory_test_with_dotted_param_is_parsed_correctly_when_testMethod_provided --- TrxLib.Tests/TestResultTests.cs | 21 ++++++++++++ TrxLib/TestResult.cs | 60 +++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/TrxLib.Tests/TestResultTests.cs b/TrxLib.Tests/TestResultTests.cs index 960d301..0abb220 100644 --- a/TrxLib.Tests/TestResultTests.cs +++ b/TrxLib.Tests/TestResultTests.cs @@ -78,6 +78,27 @@ public void Inferred_properties_are_not_inferred_from_fully_qualified_test_name_ 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() { diff --git a/TrxLib/TestResult.cs b/TrxLib/TestResult.cs index eacea11..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; + } } } From 92d8ecd042bdf906b2ec8cc9fcb7d149a340767a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 16:03:48 -0700 Subject: [PATCH 4/4] fix: address PR feedback - ordinal StringComparison and stale TDD comment - Add StringComparison.Ordinal to both StartsWith calls in TrxParser.cs to avoid locale-dependent behavior (e.g. Turkish-I problem) - Update stale 'should be RED until fixed' comment in TrxParserTests.cs to reflect that these are now passing regression tests --- TrxLib.Tests/TrxParserTests.cs | 6 +++--- TrxLib/TrxParser.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index 594cc75..9b56fd8 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -176,9 +176,9 @@ 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"); } - // ── Failing tests (TDD) ───────────────────────────────────────────────────── - // These cover attributes/elements present in sample TRX files that TrxParser - // currently silently discards. Each test should be RED until the parser is fixed. + // ── 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() diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index c3b904f..4a66e9d 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -68,14 +68,14 @@ public static TestResultSet Parse(FileInfo trxFile) { // 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 + ".") + 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)) + if (result.TestName != null && result.TestName.StartsWith(baseFqtn, StringComparison.Ordinal)) fullyQualifiedTestName = result.TestName; else fullyQualifiedTestName = baseFqtn;