From 4d0a78f56cf5cc953be76a0af28aaea6b7126339 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 22:07:08 -0700 Subject: [PATCH 01/17] feat: AOT compliance with multi-targeting (netstandard2.1 + net8.0 + net10.0) (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Makes TrxLib fully AOT-compatible by replacing `XmlSerializer` with `XDocument`/`XElement` manual parsing, adding multi-targeting, and validating with a native AOT smoke-test project in CI. ## Changes ### Library (`TrxLib`) - **Multi-target**: `netstandard2.1;net8.0;net10.0` — keeps backward compatibility while enabling `IsAotCompatible=true` on net8.0+ - **Parser rewrite**: `XmlSerializer` → `XDocument.Load()` + `XElement` navigation (fully AOT-safe, available on all targets, no `#if` branching) - **New model classes**: `Execution`, `Counters`, `ResultSummary`, `TestList`, `TestLists`, `TestEntry`, `TestEntries` - **Complete parsing**: all previously-unset fields now populated — `RunUser`, `ResultSummary`/`Counters`, `TestLists`, `TestEntries`, `UnitTest.Storage`/`Execution`, `UnitTestResult.ExecutionId`/`TestListId`/`TestType`/`RelativeResultsDirectory` - **`TestResultSet` properties**: `TestRunId`, `DeploymentRoot`, `TestSettingsName` now populated from parsed data - **Theory/parameterized test names**: FQTN now correctly appends parameter suffixes (e.g. `MethodName(arg: "value")`) - **Exception handling**: `catch (Exception)` narrowed to `catch (XmlException)` in the XML loading path - **Cleanup**: removed inert `[XmlRoot]`/`[XmlElement]`/`[XmlAttribute]` attributes and `using System.Xml.Serialization` from all model classes ### AOT Smoke Test (`TrxLib.AotSample`) - New `net10.0` console app with `PublishAot=true` that references TrxLib - Writes a minimal TRX to a temp file, parses it with `TrxParser.Parse`, and asserts the result — exercising the full `XDocument` parse path in the native binary ### CI (`.github/workflows/build-and-test.yml`) - Added `AOT Publish Validation` step to the existing matrix job (ubuntu + windows) - Runs `dotnet publish -r --self-contained` then **executes the native binary** to catch both link-time and runtime AOT regressions ## Verification - ✅ 38/38 tests pass - ✅ `dotnet build` — 0 AOT analyzer warnings on net8.0/net10.0 - ✅ `dotnet publish -r win-x64` — 0 ILLink/AOT warnings - ✅ Native binary runs and prints `TrxLib AOT validation passed.` --- .github/workflows/build-and-test.yml | 9 +- TrxLib.AotSample/Program.cs | 20 ++ TrxLib.AotSample/TrxLib.AotSample.csproj | 22 ++ TrxLib.Tests/SampleTrxFiles/theory-tests.trx | 46 ++++ TrxLib.Tests/TrxParserTests.cs | 30 ++- TrxLib.slnx | 1 + TrxLib/Counters.cs | 33 +-- TrxLib/Deployment.cs | 3 - TrxLib/ErrorInfo.cs | 4 - TrxLib/Execution.cs | 10 +- TrxLib/Output.cs | 4 - TrxLib/ResultSummary.cs | 15 +- TrxLib/Results.cs | 3 - TrxLib/TestDefinitions.cs | 3 - TrxLib/TestEntries.cs | 8 +- TrxLib/TestEntry.cs | 10 +- TrxLib/TestList.cs | 9 +- TrxLib/TestLists.cs | 8 +- TrxLib/TestMethod.cs | 6 - TrxLib/TestRun.cs | 15 +- TrxLib/TestSettings.cs | 5 - TrxLib/Times.cs | 6 - TrxLib/TrxLib.csproj | 3 +- TrxLib/TrxParser.cs | 233 +++++++++++++++++-- TrxLib/UnitTest.cs | 7 - TrxLib/UnitTestResult.cs | 14 -- 26 files changed, 371 insertions(+), 156 deletions(-) create mode 100644 TrxLib.AotSample/Program.cs create mode 100644 TrxLib.AotSample/TrxLib.AotSample.csproj create mode 100644 TrxLib.Tests/SampleTrxFiles/theory-tests.trx diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c45f3ac..397afc2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -54,4 +54,11 @@ jobs: run: dotnet build -p:ContinuousIntegrationBuild=True --no-restore --configuration Release - name: Test - run: dotnet test --no-build --configuration Release --verbosity normal \ No newline at end of file + run: dotnet test --no-build --configuration Release --verbosity normal + + - name: AOT Publish Validation + run: | + $rid = if ($env:RUNNER_OS -eq 'Windows') { 'win-x64' } else { 'linux-x64' } + dotnet publish TrxLib.AotSample/TrxLib.AotSample.csproj -r $rid -c Release --self-contained + $ext = if ($env:RUNNER_OS -eq 'Windows') { '.exe' } else { '' } + & "TrxLib.AotSample/bin/Release/net10.0/$rid/publish/TrxLib.AotSample$ext" \ No newline at end of file diff --git a/TrxLib.AotSample/Program.cs b/TrxLib.AotSample/Program.cs new file mode 100644 index 0000000..a1ec8e5 --- /dev/null +++ b/TrxLib.AotSample/Program.cs @@ -0,0 +1,20 @@ +using TrxLib; + +var sampleRoot = Path.Combine(AppContext.BaseDirectory, "SampleTrxFiles"); +if (!Directory.Exists(sampleRoot)) + throw new DirectoryNotFoundException($"Sample TRX directory not found: {sampleRoot}"); + +var sampleFiles = Directory + .EnumerateFiles(sampleRoot, "*.trx", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + +if (sampleFiles.Length == 0) + throw new InvalidOperationException($"No TRX sample files found under: {sampleRoot}"); + +foreach (var file in sampleFiles) +{ + _ = TrxParser.Parse(new FileInfo(file)); +} + +Console.WriteLine($"TrxLib AOT validation passed. Parsed {sampleFiles.Length} sample files."); diff --git a/TrxLib.AotSample/TrxLib.AotSample.csproj b/TrxLib.AotSample/TrxLib.AotSample.csproj new file mode 100644 index 0000000..ef22c20 --- /dev/null +++ b/TrxLib.AotSample/TrxLib.AotSample.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + true + true + false + + + + + + + + + + + diff --git a/TrxLib.Tests/SampleTrxFiles/theory-tests.trx b/TrxLib.Tests/SampleTrxFiles/theory-tests.trx new file mode 100644 index 0000000..3d49985 --- /dev/null +++ b/TrxLib.Tests/SampleTrxFiles/theory-tests.trx @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.8) +[xUnit.net 00:00:00.08] Discovering: TrxLib.Tests +[xUnit.net 00:00:00.13] Discovered: TrxLib.Tests +[xUnit.net 00:00:00.15] Starting: TrxLib.Tests +[xUnit.net 00:00:00.20] Finished: TrxLib.Tests + + + + \ No newline at end of file diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index 9b56fd8..eb83079 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -168,6 +168,34 @@ private static bool NotWindows() return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); } + [Fact] + public void Parse_TheoryTestsTrx_AppendsSuffixToFqtnForParameterizedTests() + { + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("theory-tests.trx"))); + results.Select(r => r.FullyQualifiedTestName) + .Should() + .Contain("Acme.Tests.MathTests.AddNumbers(left: 1, right: 2)"); + results.Select(r => r.FullyQualifiedTestName) + .Should() + .Contain("Acme.Tests.MathTests.AddNumbers(left: 0, right: 0)"); + } + + [Fact] + public void Parse_TheoryTestsTrx_DoesNotAppendSuffixForNonParameterizedTest() + { + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("theory-tests.trx"))); + results.Single(r => r.FullyQualifiedTestName == "Acme.Tests.MathTests.PlainTest") + .Should().NotBeNull(); + } + + [Fact] + public void Parse_TheoryTestsTrx_ParsesAllThreeTestResults() + { + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("theory-tests.trx"))); + results.Should().HaveCount(3); + results.Count(r => r.Outcome == TestOutcome.Passed).Should().Be(3); + } + [ConditionalFact(nameof(NotWindows))] public void Parse_Example1OSXTrx_ParsesCodebaseCorrectly() { @@ -355,4 +383,4 @@ public void Parse_Example1OSXTrx_ParsesFinishTimeCorrectly() var results = TrxParser.Parse(new FileInfo(GetSampleFilePath(Path.Combine("1", "example1_OSX.trx")))); results.CompletedTime.Should().Be(DateTimeOffset.Parse("2017-01-17T10:39:57.1294340-08:00")); } -} \ No newline at end of file +} diff --git a/TrxLib.slnx b/TrxLib.slnx index 0523140..18f741c 100644 --- a/TrxLib.slnx +++ b/TrxLib.slnx @@ -8,6 +8,7 @@ + diff --git a/TrxLib/Counters.cs b/TrxLib/Counters.cs index fe023da..e84ee0d 100644 --- a/TrxLib/Counters.cs +++ b/TrxLib/Counters.cs @@ -1,74 +1,55 @@ -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. +/// Represents the vstest-authoritative test run counters from the ResultSummary element. /// 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")] + /// Gets or sets the number of tests that produced an 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")] + /// Gets or sets the number of tests that were 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")] + /// Gets or sets the number of disconnected tests. public int Disconnected { get; set; } - /// Gets or sets the number of tests with a warning outcome. - [XmlAttribute("warning")] + /// Gets or sets the number of tests that produced warnings. 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")] + /// Gets or sets the number of tests in progress. public int InProgress { get; set; } - /// Gets or sets the number of tests that are pending. - [XmlAttribute("pending")] + /// Gets or sets the number of pending tests. public int Pending { get; set; } } diff --git a/TrxLib/Deployment.cs b/TrxLib/Deployment.cs index ba14d07..20b4836 100644 --- a/TrxLib/Deployment.cs +++ b/TrxLib/Deployment.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,6 +9,5 @@ public class Deployment /// /// Gets or sets the root directory path where test run files are deployed. /// - [XmlAttribute("runDeploymentRoot")] public string? RunDeploymentRoot { get; set; } } \ No newline at end of file diff --git a/TrxLib/ErrorInfo.cs b/TrxLib/ErrorInfo.cs index 24bda98..1262300 100644 --- a/TrxLib/ErrorInfo.cs +++ b/TrxLib/ErrorInfo.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,13 +9,11 @@ public class ErrorInfo /// /// Gets or sets the error message describing why the test failed. /// - [XmlElement("Message")] public string? Message { get; set; } /// /// Gets or sets the stack trace information from the test failure. /// Contains the call stack at the point of the exception that caused the test to fail. /// - [XmlElement("StackTrace")] public string? StackTrace { get; set; } } diff --git a/TrxLib/Execution.cs b/TrxLib/Execution.cs index c1bdd4b..c5e172e 100644 --- a/TrxLib/Execution.cs +++ b/TrxLib/Execution.cs @@ -1,17 +1,13 @@ -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. +/// Represents the Execution element inside a UnitTest definition. +/// Links the test definition to its execution record and result. /// public class Execution { /// - /// Gets or sets the execution identifier. Links this test definition to the - /// corresponding UnitTestResult via its executionId attribute. + /// Gets or sets the execution identifier. /// - [XmlAttribute("id")] public string? Id { get; set; } } diff --git a/TrxLib/Output.cs b/TrxLib/Output.cs index 79a2a2f..cb0b004 100644 --- a/TrxLib/Output.cs +++ b/TrxLib/Output.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -12,12 +10,10 @@ public class Output /// Gets or sets the error information if the test failed. /// Contains the error message and stack trace details. /// - [XmlElement("ErrorInfo")] public ErrorInfo? ErrorInfo { get; set; } /// /// Gets or sets the standard output text captured during test execution. /// - [XmlElement("StdOut")] public string? StdOut { get; set; } } diff --git a/TrxLib/ResultSummary.cs b/TrxLib/ResultSummary.cs index 91f1259..7b700db 100644 --- a/TrxLib/ResultSummary.cs +++ b/TrxLib/ResultSummary.cs @@ -1,28 +1,23 @@ -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. +/// Represents the ResultSummary element of a TRX file, containing the overall +/// outcome and vstest-authoritative test counters for the run. /// public class ResultSummary { /// - /// Gets or sets the overall outcome of the test run (e.g. "Passed", "Failed", "Completed"). + /// Gets or sets the overall outcome of the test run (e.g., "Passed", "Failed"). /// - [XmlAttribute("outcome")] public string? Outcome { get; set; } /// - /// Gets or sets the test result counters for the run. + /// Gets or sets the authoritative test counters computed by vstest. /// - [XmlElement("Counters")] public Counters? Counters { get; set; } /// - /// Gets or sets the run-level output (e.g. run-level stdout written by the test host). + /// Gets or sets the run-level output (e.g., stdout from the test host). /// - [XmlElement("Output")] public Output? Output { get; set; } } diff --git a/TrxLib/Results.cs b/TrxLib/Results.cs index aaec770..1172e0f 100644 --- a/TrxLib/Results.cs +++ b/TrxLib/Results.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,6 +9,5 @@ public class Results /// /// Gets or sets the list of unit test results from the test run. /// - [XmlElement("UnitTestResult")] public List? UnitTestResults { get; set; } } diff --git a/TrxLib/TestDefinitions.cs b/TrxLib/TestDefinitions.cs index a3df25f..65ec1b7 100644 --- a/TrxLib/TestDefinitions.cs +++ b/TrxLib/TestDefinitions.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,6 +9,5 @@ public class TestDefinitions /// /// Gets or sets the list of unit test definitions. /// - [XmlElement("UnitTest")] public List? UnitTests { get; set; } } diff --git a/TrxLib/TestEntries.cs b/TrxLib/TestEntries.cs index b3cda4e..e203843 100644 --- a/TrxLib/TestEntries.cs +++ b/TrxLib/TestEntries.cs @@ -1,14 +1,10 @@ -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. +/// Represents the TestEntries element, which indexes test definitions to their execution records. /// public class TestEntries { - /// Gets or sets the individual test entry records. - [XmlElement("TestEntry")] + /// Gets or sets the collection of test entry records. public List? Items { get; set; } } diff --git a/TrxLib/TestEntry.cs b/TrxLib/TestEntry.cs index 584f8ec..6818313 100644 --- a/TrxLib/TestEntry.cs +++ b/TrxLib/TestEntry.cs @@ -1,22 +1,16 @@ -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). +/// Represents a single TestEntry that links a test definition to its execution record. /// 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")] + /// Gets or sets the test list identifier this entry belongs to. public string? TestListId { get; set; } } diff --git a/TrxLib/TestList.cs b/TrxLib/TestList.cs index 0e2c2b1..10f7b13 100644 --- a/TrxLib/TestList.cs +++ b/TrxLib/TestList.cs @@ -1,18 +1,13 @@ -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". +/// Represents a single TestList entry used to categorize test results. /// public class TestList { - /// Gets or sets the display name of the test list. - [XmlAttribute("name")] + /// Gets or sets the name of the test list. 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 7363b2f..388778b 100644 --- a/TrxLib/TestLists.cs +++ b/TrxLib/TestLists.cs @@ -1,14 +1,10 @@ -using System.Xml.Serialization; - namespace TrxLib; /// -/// Represents the TestLists element of a TRX file. -/// Contains the list categories used to group test results. +/// Represents the TestLists element containing the list categories for a test run. /// public class TestLists { - /// Gets or sets the individual test list entries. - [XmlElement("TestList")] + /// Gets or sets the collection of test lists. public List? Items { get; set; } } diff --git a/TrxLib/TestMethod.cs b/TrxLib/TestMethod.cs index 0466b65..ba7cd31 100644 --- a/TrxLib/TestMethod.cs +++ b/TrxLib/TestMethod.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,24 +9,20 @@ public class TestMethod /// /// Gets or sets the path to the assembly containing the test method. /// - [XmlAttribute("codeBase")] public string? CodeBase { get; set; } /// /// Gets or sets the fully qualified name of the class containing the test method. /// - [XmlAttribute("className")] public string? ClassName { get; set; } /// /// Gets or sets the name of the test method. /// - [XmlAttribute("name")] public string? Name { get; set; } /// /// Gets or sets the fully qualified name of the test adapter used to run the test. /// - [XmlAttribute("adapterTypeName")] public string? AdapterTypeName { get; set; } } diff --git a/TrxLib/TestRun.cs b/TrxLib/TestRun.cs index a86241d..2dd9971 100644 --- a/TrxLib/TestRun.cs +++ b/TrxLib/TestRun.cs @@ -1,72 +1,59 @@ -using System.Xml.Serialization; - namespace TrxLib; /// /// Represents the root element of a TRX (Test Results XML) file. /// Contains all test definitions, results, and metadata about the test run. /// -[XmlRoot("TestRun", Namespace = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010")] public class TestRun { /// /// Gets or sets the collection of test results from the test run. /// - [XmlElement("Results")] public Results? Results { get; set; } /// /// Gets or sets the collection of test definitions used in the test run. /// - [XmlElement("TestDefinitions")] public TestDefinitions? TestDefinitions { get; set; } /// /// Gets or sets the name of the test run. /// - [XmlAttribute("name")] public string? Name { get; set; } /// /// Gets or sets the unique identifier of the test run. /// - [XmlAttribute("id")] public string? Id { get; set; } /// /// Gets or sets the timing information for the test run. /// - [XmlElement("Times")] public Times? Times { get; set; } /// /// Gets or sets the configuration settings used for the test run. /// - [XmlElement("TestSettings")] public TestSettings? TestSettings { get; set; } /// - /// Gets or sets the user account that initiated the test run. + /// Gets or sets the user account that initiated the test run (the runUser attribute). /// - [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/TestSettings.cs b/TrxLib/TestSettings.cs index 716e41d..bfd38fa 100644 --- a/TrxLib/TestSettings.cs +++ b/TrxLib/TestSettings.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,19 +9,16 @@ public class TestSettings /// /// Gets or sets the name of the test settings configuration. /// - [XmlAttribute("name")] public string? Name { get; set; } /// /// Gets or sets the unique identifier of the test settings. /// - [XmlAttribute("id")] public string? Id { get; set; } /// /// Gets or sets the deployment information for the test run. /// Contains details about where test files are deployed. /// - [XmlElement("Deployment")] public Deployment? Deployment { get; set; } } \ No newline at end of file diff --git a/TrxLib/Times.cs b/TrxLib/Times.cs index 873d868..da41144 100644 --- a/TrxLib/Times.cs +++ b/TrxLib/Times.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -11,24 +9,20 @@ public class Times /// /// Gets or sets the timestamp when the test run was created. /// - [XmlAttribute("creation")] public string? Creation { get; set; } /// /// Gets or sets the timestamp when the test run was queued for execution. /// - [XmlAttribute("queuing")] public string? Queuing { get; set; } /// /// Gets or sets the timestamp when the test run started execution. /// - [XmlAttribute("start")] public string? Start { get; set; } /// /// Gets or sets the timestamp when the test run finished execution. /// - [XmlAttribute("finish")] public string? Finish { get; set; } } \ No newline at end of file diff --git a/TrxLib/TrxLib.csproj b/TrxLib/TrxLib.csproj index 7ae2424..ed740ea 100644 --- a/TrxLib/TrxLib.csproj +++ b/TrxLib/TrxLib.csproj @@ -1,7 +1,8 @@  - netstandard2.1 + netstandard2.1;net8.0;net10.0 + true true true true diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 4a66e9d..1bba8bb 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -1,4 +1,4 @@ -using System.Xml.Serialization; +using System.Xml.Linq; namespace TrxLib; @@ -7,6 +7,8 @@ namespace TrxLib; /// public class TrxParser { + private static readonly XNamespace TrxNs = XNamespace.Get("http://microsoft.com/schemas/VisualStudio/TeamTest/2010"); + /// /// Parses a TRX file and converts it into a TestResultSet containing structured test results. /// @@ -15,8 +17,7 @@ public class TrxParser public static TestResultSet Parse(FileInfo trxFile) { using var stream = trxFile.OpenRead(); - var serializer = new XmlSerializer(typeof(TestRun)); - TestRun? testRun = serializer.Deserialize(stream) as TestRun; + TestRun? testRun = DeserializeTestRun(stream); if (testRun == null) return new TestResultSet(); @@ -66,19 +67,29 @@ public static TestResultSet Parse(FileInfo trxFile) { if (!string.IsNullOrEmpty(testMethodDomain.ClassName) && !string.IsNullOrEmpty(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) + string 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 = baseFqtn; + // For parameterized/theory tests, testName carries the suffix (e.g. "(arg1, arg2)"). + // Extract the short method name and append the suffix from testName if present. + var methodShortName = testMethodDomain.Name.Contains('.') + ? testMethodDomain.Name.Substring(testMethodDomain.Name.LastIndexOf('.') + 1) + : testMethodDomain.Name; + var paramSuffix = string.Empty; + if (!string.IsNullOrEmpty(result.TestName)) + { + string? candidate = null; + + if (result.TestName.StartsWith(baseFqtn, StringComparison.Ordinal)) + candidate = result.TestName.Substring(baseFqtn.Length); + else if (result.TestName.StartsWith(methodShortName, StringComparison.Ordinal)) + candidate = result.TestName.Substring(methodShortName.Length); + + if (candidate?.StartsWith("(", StringComparison.Ordinal) == true) + paramSuffix = candidate; + } + fullyQualifiedTestName = baseFqtn + paramSuffix; } else { @@ -142,8 +153,10 @@ public static TestResultSet Parse(FileInfo trxFile) var testResultSet = new TestResultSet(results) { TestRunName = testRun.Name ?? string.Empty, - TestRunId = testRun.Id ?? string.Empty, TestFilePath = trxFile.FullName, + TestRunId = testRun.Id ?? string.Empty, + DeploymentRoot = testRun.TestSettings?.Deployment?.RunDeploymentRoot ?? string.Empty, + TestSettingsName = testRun.TestSettings?.Name ?? string.Empty, OriginalTestRun = testRun }; @@ -159,4 +172,196 @@ public static TestResultSet Parse(FileInfo trxFile) return testResultSet; } + + private static TestRun? DeserializeTestRun(Stream stream) + { + XDocument doc; + try + { + doc = XDocument.Load(stream); + } + catch (System.Xml.XmlException) + { + return null; + } + + var root = doc.Root; + if (root == null) + return null; + + var testRun = new TestRun + { + Name = (string?)root.Attribute("name"), + Id = (string?)root.Attribute("id"), + RunUser = (string?)root.Attribute("runUser"), + }; + + var timesEl = root.Element(TrxNs + "Times"); + if (timesEl != null) + { + testRun.Times = new Times + { + Creation = (string?)timesEl.Attribute("creation"), + Queuing = (string?)timesEl.Attribute("queuing"), + Start = (string?)timesEl.Attribute("start"), + Finish = (string?)timesEl.Attribute("finish"), + }; + } + + var testSettingsEl = root.Element(TrxNs + "TestSettings"); + if (testSettingsEl != null) + { + testRun.TestSettings = new TestSettings + { + Name = (string?)testSettingsEl.Attribute("name"), + Id = (string?)testSettingsEl.Attribute("id"), + Deployment = testSettingsEl.Element(TrxNs + "Deployment") is XElement deployEl + ? new Deployment { RunDeploymentRoot = (string?)deployEl.Attribute("runDeploymentRoot") } + : null, + }; + } + + var testDefsEl = root.Element(TrxNs + "TestDefinitions"); + if (testDefsEl != null) + { + testRun.TestDefinitions = new TestDefinitions + { + UnitTests = testDefsEl.Elements(TrxNs + "UnitTest").Select(ut => + { + var tmEl = ut.Element(TrxNs + "TestMethod"); + return new UnitTest + { + Id = (string?)ut.Attribute("id"), + Name = (string?)ut.Attribute("name"), + Storage = (string?)ut.Attribute("storage"), + Execution = ut.Element(TrxNs + "Execution") is XElement execEl + ? new Execution { Id = (string?)execEl.Attribute("id") } + : null, + TestMethod = tmEl != null ? new TestMethod + { + CodeBase = (string?)tmEl.Attribute("codeBase"), + ClassName = (string?)tmEl.Attribute("className"), + Name = (string?)tmEl.Attribute("name"), + AdapterTypeName = (string?)tmEl.Attribute("adapterTypeName"), + } : null, + }; + }).ToList(), + }; + } + + var resultsEl = root.Element(TrxNs + "Results"); + if (resultsEl != null) + { + testRun.Results = new Results + { + UnitTestResults = resultsEl.Elements(TrxNs + "UnitTestResult").Select(ur => + { + Output? output = null; + if (ur.Element(TrxNs + "Output") is XElement outputEl) + { + ErrorInfo? errorInfo = null; + if (outputEl.Element(TrxNs + "ErrorInfo") is XElement errorInfoEl) + { + errorInfo = new ErrorInfo + { + Message = (string?)errorInfoEl.Element(TrxNs + "Message"), + StackTrace = (string?)errorInfoEl.Element(TrxNs + "StackTrace"), + }; + } + output = new Output + { + StdOut = (string?)outputEl.Element(TrxNs + "StdOut"), + ErrorInfo = errorInfo, + }; + } + return new UnitTestResult + { + TestId = (string?)ur.Attribute("testId"), + TestName = (string?)ur.Attribute("testName"), + Outcome = (string?)ur.Attribute("outcome"), + StartTime = (string?)ur.Attribute("startTime"), + EndTime = (string?)ur.Attribute("endTime"), + Duration = (string?)ur.Attribute("duration"), + ComputerName = (string?)ur.Attribute("computerName"), + ExecutionId = (string?)ur.Attribute("executionId"), + TestListId = (string?)ur.Attribute("testListId"), + TestType = (string?)ur.Attribute("testType"), + RelativeResultsDirectory = (string?)ur.Attribute("relativeResultsDirectory"), + Output = output, + }; + }).ToList(), + }; + } + + var resultSummaryEl = root.Element(TrxNs + "ResultSummary"); + if (resultSummaryEl != null) + { + Counters? counters = null; + if (resultSummaryEl.Element(TrxNs + "Counters") is XElement countersEl) + { + counters = new Counters + { + Total = (int?)countersEl.Attribute("total") ?? 0, + Executed = (int?)countersEl.Attribute("executed") ?? 0, + Passed = (int?)countersEl.Attribute("passed") ?? 0, + Failed = (int?)countersEl.Attribute("failed") ?? 0, + Error = (int?)countersEl.Attribute("error") ?? 0, + Timeout = (int?)countersEl.Attribute("timeout") ?? 0, + Aborted = (int?)countersEl.Attribute("aborted") ?? 0, + Inconclusive = (int?)countersEl.Attribute("inconclusive") ?? 0, + PassedButRunAborted = (int?)countersEl.Attribute("passedButRunAborted") ?? 0, + NotRunnable = (int?)countersEl.Attribute("notRunnable") ?? 0, + NotExecuted = (int?)countersEl.Attribute("notExecuted") ?? 0, + Disconnected = (int?)countersEl.Attribute("disconnected") ?? 0, + Warning = (int?)countersEl.Attribute("warning") ?? 0, + Completed = (int?)countersEl.Attribute("completed") ?? 0, + InProgress = (int?)countersEl.Attribute("inProgress") ?? 0, + Pending = (int?)countersEl.Attribute("pending") ?? 0, + }; + } + Output? summaryOutput = null; + if (resultSummaryEl.Element(TrxNs + "Output") is XElement summaryOutputEl) + { + summaryOutput = new Output + { + StdOut = (string?)summaryOutputEl.Element(TrxNs + "StdOut"), + }; + } + testRun.ResultSummary = new ResultSummary + { + Outcome = (string?)resultSummaryEl.Attribute("outcome"), + Counters = counters, + Output = summaryOutput, + }; + } + + var testListsEl = root.Element(TrxNs + "TestLists"); + if (testListsEl != null) + { + testRun.TestLists = new TestLists + { + Items = testListsEl.Elements(TrxNs + "TestList").Select(tl => new TestList + { + Name = (string?)tl.Attribute("name"), + Id = (string?)tl.Attribute("id"), + }).ToList(), + }; + } + + var testEntriesEl = root.Element(TrxNs + "TestEntries"); + if (testEntriesEl != null) + { + testRun.TestEntries = new TestEntries + { + Items = testEntriesEl.Elements(TrxNs + "TestEntry").Select(te => new TestEntry + { + TestId = (string?)te.Attribute("testId"), + ExecutionId = (string?)te.Attribute("executionId"), + TestListId = (string?)te.Attribute("testListId"), + }).ToList(), + }; + } + + return testRun; + } } diff --git a/TrxLib/UnitTest.cs b/TrxLib/UnitTest.cs index ad7c4de..3c02707 100644 --- a/TrxLib/UnitTest.cs +++ b/TrxLib/UnitTest.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -12,31 +10,26 @@ public class UnitTest /// Gets or sets the unique identifier of the unit test. /// This ID is referenced by UnitTestResult elements. /// - [XmlAttribute("id")] public string? Id { get; set; } /// /// Gets or sets the name of the unit test. /// - [XmlAttribute("name")] public string? Name { get; set; } /// /// Gets or sets the test method information for this unit test. /// Contains details about the method that implements the test. /// - [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 05b3a9e..3c26654 100644 --- a/TrxLib/UnitTestResult.cs +++ b/TrxLib/UnitTestResult.cs @@ -1,5 +1,3 @@ -using System.Xml.Serialization; - namespace TrxLib; /// @@ -12,73 +10,61 @@ public class UnitTestResult /// Gets or sets the unique identifier of the test definition referenced by this test result. /// This ID maps to a UnitTest element in the TestDefinitions section. /// - [XmlAttribute("testId")] public string? TestId { get; set; } /// /// Gets or sets the name of the test that was executed. /// - [XmlAttribute("testName")] public string? TestName { get; set; } /// /// Gets or sets the outcome of the test execution. /// Common values include "Passed", "Failed", "NotExecuted", "Inconclusive", "Timeout", and "Pending". /// - [XmlAttribute("outcome")] public string? Outcome { get; set; } /// /// Gets or sets the start time of the test execution in ISO 8601 format. /// - [XmlAttribute("startTime")] public string? StartTime { get; set; } /// /// Gets or sets the end time of the test execution in ISO 8601 format. /// - [XmlAttribute("endTime")] public string? EndTime { get; set; } /// /// Gets or sets the duration of the test execution, typically in the format "hh:mm:ss.fffffff". /// - [XmlAttribute("duration")] public string? Duration { get; set; } /// /// Gets or sets the name of the computer where the test was executed. /// - [XmlAttribute("computerName")] public string? ComputerName { get; set; } /// /// Gets or sets the output information of the test execution, including error information and standard output. /// - [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 5a86342ccca063509aa5e35e3a8aad79a0092c06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 22:09:06 -0700 Subject: [PATCH 02/17] Bump actions/upload-artifact from 6 to 7 (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2c42a98..03ade4c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ jobs: - name: Upload Artifacts if: startsWith(github.ref, 'refs/tags/v') - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: NuGet path: ${{ github.workspace }}/PackedNuget From 0419ec43475c0f98e403e3be797aab9e8416a9a8 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 22:10:27 -0700 Subject: [PATCH 03/17] fix: Workflow does not contain permissions (#44) Potential fix for [https://github.com/BenjaminMichaelis/TrxLib/security/code-scanning/3](https://github.com/BenjaminMichaelis/TrxLib/security/code-scanning/3) Add an explicit `permissions` block for the `build-and-test` job in `.github/workflows/build-and-test.yml`, with the minimum required scope: - `contents: read` This is the best fix because it preserves existing behavior (checkout/build/test still work) while ensuring `GITHUB_TOKEN` cannot get unintended write privileges from repo/org defaults. Edit only the `build-and-test` job section, directly under `runs-on`. _Suggested fixes powered by Copilot Autofix. Review carefully before merging._ Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 397afc2..38490ac 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -33,6 +33,8 @@ jobs: use-github-auto-merge: true build-and-test: runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: matrix: From 4a602bc08f308b671539bd3a9141d82685eca90c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 22:40:57 -0700 Subject: [PATCH 04/17] Add failing regression tests for bugs A, C, D Bug A: Aborted and other real vstest outcomes are silently mapped to NotExecuted by the switch default. The test asserts Aborted must NOT become NotExecuted. Bug C: Parser hardcodes a 3-level parent walk from the DLL path to derive TestProjectDirectory. This assumes bin\Debug\\ layout and gives the wrong directory for publish\ or other non-standard output paths. The test uses a codeBase one level deep (publish\) and asserts the project root is 1 level up, not 3. Bug D: All child-element lookups use the xmlns-qualified XName (TrxNs + element). A TRX that omits the xmlns declaration produces zero results even though the structure is otherwise valid. The test asserts 3 results from a namespace-free TRX. Adds three PII-free sample TRX files to drive the tests: - aborted-outcome.trx (single result, outcome=Aborted) - published-codebase.trx (single result, codeBase in publish\) - no-namespace.trx (3 results, no xmlns on root element) --- .../SampleTrxFiles/aborted-outcome.trx | 26 ++++++++++ TrxLib.Tests/SampleTrxFiles/no-namespace.trx | 38 +++++++++++++++ .../SampleTrxFiles/published-codebase.trx | 26 ++++++++++ TrxLib.Tests/TrxParserTests.cs | 47 +++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx create mode 100644 TrxLib.Tests/SampleTrxFiles/no-namespace.trx create mode 100644 TrxLib.Tests/SampleTrxFiles/published-codebase.trx diff --git a/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx b/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx new file mode 100644 index 0000000..c47b8ed --- /dev/null +++ b/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TrxLib.Tests/SampleTrxFiles/no-namespace.trx b/TrxLib.Tests/SampleTrxFiles/no-namespace.trx new file mode 100644 index 0000000..b150c44 --- /dev/null +++ b/TrxLib.Tests/SampleTrxFiles/no-namespace.trx @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TrxLib.Tests/SampleTrxFiles/published-codebase.trx b/TrxLib.Tests/SampleTrxFiles/published-codebase.trx new file mode 100644 index 0000000..d598cd8 --- /dev/null +++ b/TrxLib.Tests/SampleTrxFiles/published-codebase.trx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index eb83079..df25c05 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -325,6 +325,53 @@ public void Parse_Example2WindowsTrx_TestsDoNotAppearWithMoreThanOneOutcome() results.Count.Should().Be(recombinedCount); } + // ── Bug regression tests (currently FAILING — prove known bugs) ────────────── + + [Fact] + public void Parse_AbortedOutcomeTrx_AbortedIsNotSilentlyMappedToNotExecuted() + { + // Bug A: The parser's switch default maps any unrecognised outcome string to NotExecuted, + // silently losing information. "Aborted" is a real vstest outcome that differs from + // NotExecuted (which means "deliberately skipped"). It should NOT become NotExecuted. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("aborted-outcome.trx"))); + results.Should().HaveCount(1); + results.Single().Outcome.Should().NotBe(TestOutcome.NotExecuted, + "\"Aborted\" is a distinct vstest outcome and must not be silently mapped to NotExecuted"); + } + + [Fact] + public void Parse_NoNamespaceTrx_ParsesResultsWithoutNamespace() + { + // Bug D: Every child-element lookup in the parser uses a hardcoded namespace + // (TrxNs + "Results", TrxNs + "UnitTestResult", etc.). A TRX file that omits + // the standard xmlns declaration produces 0 results even though the structure is valid. + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("no-namespace.trx"))); + results.Should().HaveCount(3, + "the parser must fall back to namespace-agnostic element matching when xmlns is absent"); + } + + [Fact] + public void Parse_PublishedCodeBaseTrx_TestProjectDirectoryIsProjectRoot() + { + // Bug C: The parser walks exactly 3 parent directories from the DLL to derive + // TestProjectDirectory. This works for bin\Debug\\ (3 levels) but gives the + // wrong directory for any other layout. A "publish" output places the DLL one level + // below the project root, so going up 3 levels overshoots by 2. + // + // codeBase = C:\dev\acme-project\publish\Acme.Tests.dll + // DLL dir = C:\dev\acme-project\publish\ + // Up 3 = C:\ ← current (wrong) + // Expected = C:\dev\acme-project\ ← project root (1 level up from publish\) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; // TRX file contains Windows-style paths; only meaningful on Windows + } + var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("published-codebase.trx"))); + results.Should().HaveCount(1); + results.Single().TestProjectDirectory?.FullName.Should().Be(@"C:\dev\acme-project", + "TestProjectDirectory should be the project root, not 3 arbitrary levels above the DLL"); + } + [Fact] public void Parse_ComplexTrx_ParsesTestRunNameCorrectly() { From 4fe94d7510ba1d36583478823635e22664005aa6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 22:50:46 -0700 Subject: [PATCH 05/17] Fix bugs A, C, D in TrxParser Bug A: Map all vstest outcome strings to dedicated TestOutcome enum values instead of silently collapsing unknown outcomes to NotExecuted. Adds Aborted, Disconnected, Warning, Error, NotRunnable, PassedButRunAborted, and InProgress to TestOutcome. Bug C: Replace the hardcoded 3-level parent walk with a FindProjectDirectory heuristic that walks up from the DLL directory, skipping known build-output segments (bin, obj, debug, release, publish, platform names, and .NET TFM patterns) until it reaches the project root. This correctly handles publish\, custom OutputPath, and other non-standard build layouts without breaking the standard bin\Debug\\ case. Bug D: Detect whether the TRX document uses the standard xmlns at parse time (var ns = root.Name.Namespace == XNamespace.None ? XNamespace.None : TrxNs) and use the detected namespace for all child-element lookups. TRX files that omit the xmlns declaration now parse correctly instead of returning zero results. --- TrxLib/TestOutcome.cs | 38 +++++++++++++++++- TrxLib/TrxParser.cs | 90 +++++++++++++++++++++++++++++-------------- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/TrxLib/TestOutcome.cs b/TrxLib/TestOutcome.cs index f94c4b6..c83407d 100644 --- a/TrxLib/TestOutcome.cs +++ b/TrxLib/TestOutcome.cs @@ -33,5 +33,41 @@ public enum TestOutcome /// /// The test is awaiting execution or further action. /// - Pending + Pending, + + /// + /// The test run was aborted before the test completed. This is distinct from + /// (deliberately skipped) — the test was running but stopped. + /// + Aborted, + + /// + /// The test agent was disconnected during execution. + /// + Disconnected, + + /// + /// The test passed but produced warnings. + /// + Warning, + + /// + /// An infrastructure error occurred during test execution (not a test assertion failure). + /// + Error, + + /// + /// The test could not be run, typically due to incorrect configuration or missing dependencies. + /// + NotRunnable, + + /// + /// The individual test passed, but the overall test run was aborted. + /// + PassedButRunAborted, + + /// + /// The test was still in progress when results were collected. + /// + InProgress } diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 1bba8bb..25bdd13 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -49,6 +49,13 @@ public static TestResultSet Parse(FileInfo trxFile) "inconclusive" => TestOutcome.Inconclusive, "timeout" => TestOutcome.Timeout, "pending" => TestOutcome.Pending, + "aborted" => TestOutcome.Aborted, + "disconnected" => TestOutcome.Disconnected, + "warning" => TestOutcome.Warning, + "error" => TestOutcome.Error, + "notrunnable" => TestOutcome.NotRunnable, + "passedbutrunaborted" => TestOutcome.PassedButRunAborted, + "inprogress" => TestOutcome.InProgress, _ => TestOutcome.NotExecuted }; @@ -112,13 +119,7 @@ public static TestResultSet Parse(FileInfo trxFile) DirectoryInfo? testProjectDirectory = null; if (codebaseFile != null && codebaseFile.Directory != null) { - // 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++) - { - dir = dir.Parent; - } - testProjectDirectory = dir; + testProjectDirectory = FindProjectDirectory(codebaseFile.Directory); } // Extract StdOut if available @@ -173,6 +174,34 @@ public static TestResultSet Parse(FileInfo trxFile) return testResultSet; } + private static readonly HashSet KnownBuildOutputDirs = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", "debug", "release", "publish", "x86", "x64", "arm", "arm64", "anycpu", "any cpu" + }; + + private static DirectoryInfo? FindProjectDirectory(DirectoryInfo dllDirectory) + { + var dir = dllDirectory; + while (dir.Parent != null) + { + if (!IsKnownBuildOutputDir(dir.Name)) + return dir; + dir = dir.Parent; + } + return dllDirectory; + } + + private static bool IsKnownBuildOutputDir(string name) => + KnownBuildOutputDirs.Contains(name) || IsDotNetTfm(name); + + // A .NET TFM directory name starts with "net" or "mono" and contains a digit + // (e.g. net10.0, netcoreapp3.1, net48, monoandroid10.0). + private static bool IsDotNetTfm(string name) => + name.Length > 4 && + (name.StartsWith("net", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("mono", StringComparison.OrdinalIgnoreCase)) && + name.Any(char.IsDigit); + private static TestRun? DeserializeTestRun(Stream stream) { XDocument doc; @@ -189,6 +218,9 @@ public static TestResultSet Parse(FileInfo trxFile) if (root == null) return null; + // Support TRX files that omit the xmlns declaration: fall back to no-namespace lookups. + var ns = root.Name.Namespace == XNamespace.None ? XNamespace.None : TrxNs; + var testRun = new TestRun { Name = (string?)root.Attribute("name"), @@ -196,7 +228,7 @@ public static TestResultSet Parse(FileInfo trxFile) RunUser = (string?)root.Attribute("runUser"), }; - var timesEl = root.Element(TrxNs + "Times"); + var timesEl = root.Element(ns + "Times"); if (timesEl != null) { testRun.Times = new Times @@ -208,33 +240,33 @@ public static TestResultSet Parse(FileInfo trxFile) }; } - var testSettingsEl = root.Element(TrxNs + "TestSettings"); + var testSettingsEl = root.Element(ns + "TestSettings"); if (testSettingsEl != null) { testRun.TestSettings = new TestSettings { Name = (string?)testSettingsEl.Attribute("name"), Id = (string?)testSettingsEl.Attribute("id"), - Deployment = testSettingsEl.Element(TrxNs + "Deployment") is XElement deployEl + Deployment = testSettingsEl.Element(ns + "Deployment") is XElement deployEl ? new Deployment { RunDeploymentRoot = (string?)deployEl.Attribute("runDeploymentRoot") } : null, }; } - var testDefsEl = root.Element(TrxNs + "TestDefinitions"); + var testDefsEl = root.Element(ns + "TestDefinitions"); if (testDefsEl != null) { testRun.TestDefinitions = new TestDefinitions { - UnitTests = testDefsEl.Elements(TrxNs + "UnitTest").Select(ut => + UnitTests = testDefsEl.Elements(ns + "UnitTest").Select(ut => { - var tmEl = ut.Element(TrxNs + "TestMethod"); + var tmEl = ut.Element(ns + "TestMethod"); return new UnitTest { Id = (string?)ut.Attribute("id"), Name = (string?)ut.Attribute("name"), Storage = (string?)ut.Attribute("storage"), - Execution = ut.Element(TrxNs + "Execution") is XElement execEl + Execution = ut.Element(ns + "Execution") is XElement execEl ? new Execution { Id = (string?)execEl.Attribute("id") } : null, TestMethod = tmEl != null ? new TestMethod @@ -249,28 +281,28 @@ public static TestResultSet Parse(FileInfo trxFile) }; } - var resultsEl = root.Element(TrxNs + "Results"); + var resultsEl = root.Element(ns + "Results"); if (resultsEl != null) { testRun.Results = new Results { - UnitTestResults = resultsEl.Elements(TrxNs + "UnitTestResult").Select(ur => + UnitTestResults = resultsEl.Elements(ns + "UnitTestResult").Select(ur => { Output? output = null; - if (ur.Element(TrxNs + "Output") is XElement outputEl) + if (ur.Element(ns + "Output") is XElement outputEl) { ErrorInfo? errorInfo = null; - if (outputEl.Element(TrxNs + "ErrorInfo") is XElement errorInfoEl) + if (outputEl.Element(ns + "ErrorInfo") is XElement errorInfoEl) { errorInfo = new ErrorInfo { - Message = (string?)errorInfoEl.Element(TrxNs + "Message"), - StackTrace = (string?)errorInfoEl.Element(TrxNs + "StackTrace"), + Message = (string?)errorInfoEl.Element(ns + "Message"), + StackTrace = (string?)errorInfoEl.Element(ns + "StackTrace"), }; } output = new Output { - StdOut = (string?)outputEl.Element(TrxNs + "StdOut"), + StdOut = (string?)outputEl.Element(ns + "StdOut"), ErrorInfo = errorInfo, }; } @@ -293,11 +325,11 @@ public static TestResultSet Parse(FileInfo trxFile) }; } - var resultSummaryEl = root.Element(TrxNs + "ResultSummary"); + var resultSummaryEl = root.Element(ns + "ResultSummary"); if (resultSummaryEl != null) { Counters? counters = null; - if (resultSummaryEl.Element(TrxNs + "Counters") is XElement countersEl) + if (resultSummaryEl.Element(ns + "Counters") is XElement countersEl) { counters = new Counters { @@ -320,11 +352,11 @@ public static TestResultSet Parse(FileInfo trxFile) }; } Output? summaryOutput = null; - if (resultSummaryEl.Element(TrxNs + "Output") is XElement summaryOutputEl) + if (resultSummaryEl.Element(ns + "Output") is XElement summaryOutputEl) { summaryOutput = new Output { - StdOut = (string?)summaryOutputEl.Element(TrxNs + "StdOut"), + StdOut = (string?)summaryOutputEl.Element(ns + "StdOut"), }; } testRun.ResultSummary = new ResultSummary @@ -335,12 +367,12 @@ public static TestResultSet Parse(FileInfo trxFile) }; } - var testListsEl = root.Element(TrxNs + "TestLists"); + var testListsEl = root.Element(ns + "TestLists"); if (testListsEl != null) { testRun.TestLists = new TestLists { - Items = testListsEl.Elements(TrxNs + "TestList").Select(tl => new TestList + Items = testListsEl.Elements(ns + "TestList").Select(tl => new TestList { Name = (string?)tl.Attribute("name"), Id = (string?)tl.Attribute("id"), @@ -348,12 +380,12 @@ public static TestResultSet Parse(FileInfo trxFile) }; } - var testEntriesEl = root.Element(TrxNs + "TestEntries"); + var testEntriesEl = root.Element(ns + "TestEntries"); if (testEntriesEl != null) { testRun.TestEntries = new TestEntries { - Items = testEntriesEl.Elements(TrxNs + "TestEntry").Select(te => new TestEntry + Items = testEntriesEl.Elements(ns + "TestEntry").Select(te => new TestEntry { TestId = (string?)te.Attribute("testId"), ExecutionId = (string?)te.Attribute("executionId"), From 87590e99f5f098a6a85e836cc93873e88a2cf5e9 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 23:11:14 -0700 Subject: [PATCH 06/17] Clean up regression test comments - Drop stale '(currently FAILING - prove known bugs)' section header - Remove 'Bug A/C/D:' labels and descriptions of old broken behavior - Trim verbose path diagram in the publish-layout test - Assertions and test names already capture the intent --- TrxLib.Tests/TrxParserTests.cs | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index df25c05..bbd6b0c 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -325,14 +325,11 @@ public void Parse_Example2WindowsTrx_TestsDoNotAppearWithMoreThanOneOutcome() results.Count.Should().Be(recombinedCount); } - // ── Bug regression tests (currently FAILING — prove known bugs) ────────────── + // ── Regression tests ───────────────────────────────────────────────────────── [Fact] public void Parse_AbortedOutcomeTrx_AbortedIsNotSilentlyMappedToNotExecuted() { - // Bug A: The parser's switch default maps any unrecognised outcome string to NotExecuted, - // silently losing information. "Aborted" is a real vstest outcome that differs from - // NotExecuted (which means "deliberately skipped"). It should NOT become NotExecuted. var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("aborted-outcome.trx"))); results.Should().HaveCount(1); results.Single().Outcome.Should().NotBe(TestOutcome.NotExecuted, @@ -342,9 +339,6 @@ public void Parse_AbortedOutcomeTrx_AbortedIsNotSilentlyMappedToNotExecuted() [Fact] public void Parse_NoNamespaceTrx_ParsesResultsWithoutNamespace() { - // Bug D: Every child-element lookup in the parser uses a hardcoded namespace - // (TrxNs + "Results", TrxNs + "UnitTestResult", etc.). A TRX file that omits - // the standard xmlns declaration produces 0 results even though the structure is valid. var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("no-namespace.trx"))); results.Should().HaveCount(3, "the parser must fall back to namespace-agnostic element matching when xmlns is absent"); @@ -353,23 +347,15 @@ public void Parse_NoNamespaceTrx_ParsesResultsWithoutNamespace() [Fact] public void Parse_PublishedCodeBaseTrx_TestProjectDirectoryIsProjectRoot() { - // Bug C: The parser walks exactly 3 parent directories from the DLL to derive - // TestProjectDirectory. This works for bin\Debug\\ (3 levels) but gives the - // wrong directory for any other layout. A "publish" output places the DLL one level - // below the project root, so going up 3 levels overshoots by 2. - // - // codeBase = C:\dev\acme-project\publish\Acme.Tests.dll - // DLL dir = C:\dev\acme-project\publish\ - // Up 3 = C:\ ← current (wrong) - // Expected = C:\dev\acme-project\ ← project root (1 level up from publish\) + // The sample TRX uses Windows-style paths; skip on other platforms. if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; // TRX file contains Windows-style paths; only meaningful on Windows - } + return; + + // publish\ is a single-level output directory; TestProjectDirectory should be its parent. var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("published-codebase.trx"))); results.Should().HaveCount(1); results.Single().TestProjectDirectory?.FullName.Should().Be(@"C:\dev\acme-project", - "TestProjectDirectory should be the project root, not 3 arbitrary levels above the DLL"); + "TestProjectDirectory should be the project root, not an arbitrary number of levels above the DLL"); } [Fact] From db5dcb94c3f6d5c40996a4dd27ab96224b3063df Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 23:16:56 -0700 Subject: [PATCH 07/17] Make publish-layout test OS-agnostic Switch published-codebase.trx from a Windows-specific C:\ path to a forward-slash path (/home/user/acme-project/publish/Acme.Tests.dll). .NET normalises forward slashes on all platforms, so FileInfo and DirectoryInfo behave correctly on both Windows and Linux/macOS. Assert TestProjectDirectory.Name instead of FullName so the test checks the semantic invariant (resolved directory is the project root) without depending on the absolute path of the current drive. Drop the Windows-only platform guard entirely. --- TrxLib.Tests/SampleTrxFiles/published-codebase.trx | 4 ++-- TrxLib.Tests/TrxParserTests.cs | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/TrxLib.Tests/SampleTrxFiles/published-codebase.trx b/TrxLib.Tests/SampleTrxFiles/published-codebase.trx index d598cd8..f33a3a6 100644 --- a/TrxLib.Tests/SampleTrxFiles/published-codebase.trx +++ b/TrxLib.Tests/SampleTrxFiles/published-codebase.trx @@ -8,9 +8,9 @@ - + - + diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index bbd6b0c..a95fbb8 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -347,14 +347,10 @@ public void Parse_NoNamespaceTrx_ParsesResultsWithoutNamespace() [Fact] public void Parse_PublishedCodeBaseTrx_TestProjectDirectoryIsProjectRoot() { - // The sample TRX uses Windows-style paths; skip on other platforms. - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - // publish\ is a single-level output directory; TestProjectDirectory should be its parent. var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("published-codebase.trx"))); results.Should().HaveCount(1); - results.Single().TestProjectDirectory?.FullName.Should().Be(@"C:\dev\acme-project", + results.Single().TestProjectDirectory?.Name.Should().Be("acme-project", "TestProjectDirectory should be the project root, not an arbitrary number of levels above the DLL"); } From 76695307d7d11c27f5f101d2dced1f77406b696c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 23:40:23 -0700 Subject: [PATCH 08/17] Address PR review feedback - Use root element namespace directly for XML lookups (supports non-2010 namespaces and no-namespace files) - Return filesystem root when project-directory heuristic never exits build-output segments - Strengthen aborted outcome regression test to assert exact mapping --- TrxLib.Tests/TrxParserTests.cs | 3 +-- TrxLib/TrxParser.cs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index a95fbb8..b2811a1 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -332,8 +332,7 @@ public void Parse_AbortedOutcomeTrx_AbortedIsNotSilentlyMappedToNotExecuted() { var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("aborted-outcome.trx"))); results.Should().HaveCount(1); - results.Single().Outcome.Should().NotBe(TestOutcome.NotExecuted, - "\"Aborted\" is a distinct vstest outcome and must not be silently mapped to NotExecuted"); + results.Single().Outcome.Should().Be(TestOutcome.Aborted); } [Fact] diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 593f337..efd14fb 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -189,7 +189,7 @@ public static TestResultSet Parse(FileInfo trxFile) return dir; dir = dir.Parent; } - return dllDirectory; + return dir; } private static bool IsKnownBuildOutputDir(string name) => @@ -220,8 +220,7 @@ private static bool IsDotNetTfm(string name) => if (root == null) return null; - // Support TRX files that omit the xmlns declaration: fall back to no-namespace lookups. - var ns = root.Name.Namespace == XNamespace.None ? XNamespace.None : TrxNs; + var ns = root.Name.Namespace; var testRun = new TestRun From d92c641d039b0cb5a622054cedcf7bbf2e419af1 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 23:56:32 -0700 Subject: [PATCH 09/17] Restore main-branch outcome mapping rationale comment Reintroduced the explanatory note for null outcome attribute mapping to TestOutcome.Error, matching the upstream vstest serialization behavior captured in origin/main. --- TrxLib/TrxParser.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index e23d90b..7690dbd 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -57,6 +57,9 @@ public static TestResultSet Parse(FileInfo trxFile) "passedbutrunaborted" => TestOutcome.PassedButRunAborted, "inprogress" => TestOutcome.InProgress, "completed" => TestOutcome.Completed, + // 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 }; From 28fd82b3fbcef3e4e1d50a5443cb626219b85c50 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:03:56 -0700 Subject: [PATCH 10/17] Align TrxParser style with main patterns - Remove explanatory outcome comment per feedback - Format outcome switch with aligned pattern arms - Prefer pattern matching style (is not null) for element checks - Keep existing merge behavior (namespace-aware parsing + directory heuristic) --- TrxLib/TrxParser.cs | 50 +++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 7690dbd..5202cd1 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -43,25 +43,22 @@ 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, - "aborted" => TestOutcome.Aborted, - "disconnected" => TestOutcome.Disconnected, - "warning" => TestOutcome.Warning, - "error" => TestOutcome.Error, - "notrunnable" => TestOutcome.NotRunnable, - "passedbutrunaborted" => TestOutcome.PassedButRunAborted, - "inprogress" => TestOutcome.InProgress, - "completed" => TestOutcome.Completed, - // 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 + "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, + null => TestOutcome.Error, + _ => TestOutcome.NotExecuted }; DateTimeOffset? resultStartTime = null, resultEndTime = null; @@ -242,7 +239,6 @@ private static bool IsDotNetTfm(string name) => var ns = root.Name.Namespace; - var testRun = new TestRun { Name = (string?)root.Attribute("name"), @@ -251,7 +247,7 @@ private static bool IsDotNetTfm(string name) => }; var timesEl = root.Element(ns + "Times"); - if (timesEl != null) + if (timesEl is not null) { testRun.Times = new Times { @@ -263,7 +259,7 @@ private static bool IsDotNetTfm(string name) => } var testSettingsEl = root.Element(ns + "TestSettings"); - if (testSettingsEl != null) + if (testSettingsEl is not null) { testRun.TestSettings = new TestSettings { @@ -276,7 +272,7 @@ private static bool IsDotNetTfm(string name) => } var testDefsEl = root.Element(ns + "TestDefinitions"); - if (testDefsEl != null) + if (testDefsEl is not null) { testRun.TestDefinitions = new TestDefinitions { @@ -304,7 +300,7 @@ private static bool IsDotNetTfm(string name) => } var resultsEl = root.Element(ns + "Results"); - if (resultsEl != null) + if (resultsEl is not null) { testRun.Results = new Results { @@ -348,7 +344,7 @@ private static bool IsDotNetTfm(string name) => } var resultSummaryEl = root.Element(ns + "ResultSummary"); - if (resultSummaryEl != null) + if (resultSummaryEl is not null) { Counters? counters = null; if (resultSummaryEl.Element(ns + "Counters") is XElement countersEl) @@ -390,7 +386,7 @@ private static bool IsDotNetTfm(string name) => } var testListsEl = root.Element(ns + "TestLists"); - if (testListsEl != null) + if (testListsEl is not null) { testRun.TestLists = new TestLists { @@ -403,7 +399,7 @@ private static bool IsDotNetTfm(string name) => } var testEntriesEl = root.Element(ns + "TestEntries"); - if (testEntriesEl != null) + if (testEntriesEl is not null) { testRun.TestEntries = new TestEntries { From c06a5bb7b6062740f6c8fd8d99530734b08c14f2 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:13:16 -0700 Subject: [PATCH 11/17] Replace synthetic aborted fixture with real TRX capture Generated a real TRX from a temporary test run that was aborted via vstest hang-blame collection, copied it into SampleTrxFiles, and scrubbed high-level PII (user/machine/workspace paths). Updated the regression test to assert against the real captured fixture shape (passed unit result with aborted run summary). --- .../SampleTrxFiles/aborted-outcome.trx | 53 ++++++++++++++----- TrxLib.Tests/TrxParserTests.cs | 5 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx b/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx index c47b8ed..58d1d1b 100644 --- a/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx +++ b/TrxLib.Tests/SampleTrxFiles/aborted-outcome.trx @@ -1,26 +1,55 @@ - - - - - + + + + + - + - - - + + + - + - - + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.8) +[xUnit.net 00:00:00.08] Discovering: TrxAbortGen +[xUnit.net 00:00:00.12] Discovered: TrxAbortGen +[xUnit.net 00:00:00.14] Starting: TrxAbortGen +[createdump] Writing full dump for process 120264 to file C:\Temp\sample\testhost_120264_20260517T000946_hangdump.dmp +[createdump] Dump successfully written in 1772ms + + + + + The active test run was aborted. Reason: Test host process crashed + + + Data collector 'Blame' message: The specified inactivity time of 5 seconds has elapsed. Collecting hang dumps from testhost and its child processes. + + + + + + + + + + + + + + diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index b2811a1..46934f9 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -328,11 +328,12 @@ public void Parse_Example2WindowsTrx_TestsDoNotAppearWithMoreThanOneOutcome() // ── Regression tests ───────────────────────────────────────────────────────── [Fact] - public void Parse_AbortedOutcomeTrx_AbortedIsNotSilentlyMappedToNotExecuted() + public void Parse_AbortedRunTrx_ParsesRealAbortedRunFixture() { var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("aborted-outcome.trx"))); results.Should().HaveCount(1); - results.Single().Outcome.Should().Be(TestOutcome.Aborted); + results.Single().Outcome.Should().Be(TestOutcome.Passed); + results.OriginalTestRun?.ResultSummary?.Outcome.Should().Be("Failed"); } [Fact] From 182452e0b955f434a4ab96b13c080dc41c0eedff Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:16:31 -0700 Subject: [PATCH 12/17] Add repository instructions for real TRX fixtures Document the required workflow for TRX test fixtures: - generate from temporary tests via dotnet test TRX output - copy into sample fixtures - scrub high-level PII - avoid hand-authored synthetic TRX when real capture is possible --- .github/instructions/test-writing.instructions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/instructions/test-writing.instructions.md diff --git a/.github/instructions/test-writing.instructions.md b/.github/instructions/test-writing.instructions.md new file mode 100644 index 0000000..4f5d9dc --- /dev/null +++ b/.github/instructions/test-writing.instructions.md @@ -0,0 +1,12 @@ +# TRX fixture policy for tests + +When adding or updating sample `.trx` files in this repository, use **real captured TRX output**, not synthetic hand-written fixtures. + +Required workflow: + +1. Create temporary tests that trigger the condition you need in the TRX. +2. Run `dotnet test` with TRX output enabled to generate a real file. +3. Copy that generated TRX file into `TrxLib.Tests/SampleTrxFiles/` (renaming is fine). +4. Remove high-level personal/environment PII from the file (for example: username, machine name, local workspace paths), while preserving the TRX structure and semantics needed by tests. + +Do not fabricate TRX XML by hand when a real capture is possible. From ff900598d84d6ae1532fa7a2e3cd27e9d2115b0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:35:09 +0000 Subject: [PATCH 13/17] convert TRX fixture policy from instruction file to skill file Agent-Logs-Url: https://github.com/BenjaminMichaelis/TrxLib/sessions/8b8f74ee-ab61-4860-b945-52640f04bb44 Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com> --- .../trx-fixture-policy/SKILL.md} | 5 +++++ 1 file changed, 5 insertions(+) rename .github/{instructions/test-writing.instructions.md => skills/trx-fixture-policy/SKILL.md} (72%) diff --git a/.github/instructions/test-writing.instructions.md b/.github/skills/trx-fixture-policy/SKILL.md similarity index 72% rename from .github/instructions/test-writing.instructions.md rename to .github/skills/trx-fixture-policy/SKILL.md index 4f5d9dc..a9207ca 100644 --- a/.github/instructions/test-writing.instructions.md +++ b/.github/skills/trx-fixture-policy/SKILL.md @@ -1,3 +1,8 @@ +--- +name: trx-fixture-policy +description: Use this skill when adding or updating sample .trx files in TrxLib.Tests/SampleTrxFiles. It describes the required workflow for capturing real TRX output from dotnet test rather than hand-writing synthetic XML. +--- + # TRX fixture policy for tests When adding or updating sample `.trx` files in this repository, use **real captured TRX output**, not synthetic hand-written fixtures. From 04500cf89dec05810e03674200dcfe4f66089632 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:36:02 -0700 Subject: [PATCH 14/17] Rename skill to 'writing-test' and update description Updated skill name and description for clarity. --- .github/skills/trx-fixture-policy/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/skills/trx-fixture-policy/SKILL.md b/.github/skills/trx-fixture-policy/SKILL.md index a9207ca..5daf218 100644 --- a/.github/skills/trx-fixture-policy/SKILL.md +++ b/.github/skills/trx-fixture-policy/SKILL.md @@ -1,6 +1,6 @@ --- -name: trx-fixture-policy -description: Use this skill when adding or updating sample .trx files in TrxLib.Tests/SampleTrxFiles. It describes the required workflow for capturing real TRX output from dotnet test rather than hand-writing synthetic XML. +name: writing-test +description: Use this skill when writing tests. --- # TRX fixture policy for tests From 4d6446e715d3fb702c4a08db3413aa87d4daf490 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:36:52 -0700 Subject: [PATCH 15/17] Rename skill from 'writing-test' to 'test-writing' --- .github/skills/{trx-fixture-policy => test-writing}/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/skills/{trx-fixture-policy => test-writing}/SKILL.md (97%) diff --git a/.github/skills/trx-fixture-policy/SKILL.md b/.github/skills/test-writing/SKILL.md similarity index 97% rename from .github/skills/trx-fixture-policy/SKILL.md rename to .github/skills/test-writing/SKILL.md index 5daf218..d0873d7 100644 --- a/.github/skills/trx-fixture-policy/SKILL.md +++ b/.github/skills/test-writing/SKILL.md @@ -1,5 +1,5 @@ --- -name: writing-test +name: test-writing description: Use this skill when writing tests. --- From 08f81ba7459b9ab012af701ba78eb8cfc4ee4a02 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:58:12 -0700 Subject: [PATCH 16/17] Replace synthetic no-namespace.trx with real capture; remove published-codebase.trx no-namespace.trx is now a verbatim copy of a real NUnit TRX captured by joaoopereira without an xmlns attribute on the root element, confirming this is a genuine real-world format variant the parser must handle. Source: https://github.com/joaoopereira/dotnet-test-rerun/blob/main/test/dotnet-test-rerun.UnitTests/Fixtures/RerunCommand/NUnitTrxFileWithOneFailedTest.trx published-codebase.trx had no real-world equivalent found on GitHub. A publish-path codeBase outside any bin\ ancestor does not appear in public repos, so there is no proven bug to test for here. The synthetic fixture and its test are removed. --- TrxLib.Tests/SampleTrxFiles/no-namespace.trx | 73 +++++++++++++------ .../SampleTrxFiles/published-codebase.trx | 26 ------- TrxLib.Tests/TrxParserTests.cs | 14 +--- 3 files changed, 55 insertions(+), 58 deletions(-) delete mode 100644 TrxLib.Tests/SampleTrxFiles/published-codebase.trx diff --git a/TrxLib.Tests/SampleTrxFiles/no-namespace.trx b/TrxLib.Tests/SampleTrxFiles/no-namespace.trx index b150c44..d4deedd 100644 --- a/TrxLib.Tests/SampleTrxFiles/no-namespace.trx +++ b/TrxLib.Tests/SampleTrxFiles/no-namespace.trx @@ -1,38 +1,69 @@ - - - - + + + + - - - + + + + + + + Expected string length 5 but was 7. Strings differ at index 5. + Expected: "value" + But was: "value 2" + ----------------^ + + at NUnitTestExample.Tests.SimpleStringCompare() in C:\Users\Utilizador\RiderProjects\TestSolution\NUnitTestExample\SimpleTest.cs:line 17 + + + + + - - - + + + - - - + + + - - - + + + + + + + + + + + - - - + + + + + - - + + + + NUnit Adapter 4.2.0.0: Test execution started +Running all tests in C:\Users\Utilizador\RiderProjects\TestSolution\NUnitTestExample\bin\Debug\net6.0\NUnitTestExample.dll + NUnit3TestExecutor discovered 5 of 5 NUnit test cases using Current Discovery mode, Non-Explicit run +NUnit Adapter 4.2.0.0: Test execution complete + + diff --git a/TrxLib.Tests/SampleTrxFiles/published-codebase.trx b/TrxLib.Tests/SampleTrxFiles/published-codebase.trx deleted file mode 100644 index f33a3a6..0000000 --- a/TrxLib.Tests/SampleTrxFiles/published-codebase.trx +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/TrxLib.Tests/TrxParserTests.cs b/TrxLib.Tests/TrxParserTests.cs index 46934f9..b670c02 100644 --- a/TrxLib.Tests/TrxParserTests.cs +++ b/TrxLib.Tests/TrxParserTests.cs @@ -336,24 +336,16 @@ public void Parse_AbortedRunTrx_ParsesRealAbortedRunFixture() results.OriginalTestRun?.ResultSummary?.Outcome.Should().Be("Failed"); } + // Real NUnit TRX captured without xmlns on the root element, sourced from: + // https://github.com/joaoopereira/dotnet-test-rerun/blob/main/test/dotnet-test-rerun.UnitTests/Fixtures/RerunCommand/NUnitTrxFileWithOneFailedTest.trx [Fact] public void Parse_NoNamespaceTrx_ParsesResultsWithoutNamespace() { var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("no-namespace.trx"))); - results.Should().HaveCount(3, + results.Should().HaveCount(5, "the parser must fall back to namespace-agnostic element matching when xmlns is absent"); } - [Fact] - public void Parse_PublishedCodeBaseTrx_TestProjectDirectoryIsProjectRoot() - { - // publish\ is a single-level output directory; TestProjectDirectory should be its parent. - var results = TrxParser.Parse(new FileInfo(GetSampleFilePath("published-codebase.trx"))); - results.Should().HaveCount(1); - results.Single().TestProjectDirectory?.Name.Should().Be("acme-project", - "TestProjectDirectory should be the project root, not an arbitrary number of levels above the DLL"); - } - [Fact] public void Parse_ComplexTrx_ParsesTestRunNameCorrectly() { From f491c0eb4b3b4ea6438618c7a6bd5972336fbe07 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 01:03:02 -0700 Subject: [PATCH 17/17] Remove unproven publish-path fallback from FindProjectDirectory The fallback heuristic (KnownBuildOutputDirs, IsDotNetTfm, second walk) was added to handle codeBase paths under a publish\ directory with no bin\ ancestor. No real-world TRX file with that layout was found on GitHub, so there is no proven bug to solve. Removing the dead code keeps FindProjectDirectory simple and only as capable as the evidence supports. --- TrxLib/TrxParser.cs | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/TrxLib/TrxParser.cs b/TrxLib/TrxParser.cs index 5202cd1..2aa872c 100644 --- a/TrxLib/TrxParser.cs +++ b/TrxLib/TrxParser.cs @@ -182,14 +182,8 @@ public static TestResultSet Parse(FileInfo trxFile) return testResultSet; } - private static readonly HashSet KnownBuildOutputDirs = new(StringComparer.OrdinalIgnoreCase) - { - "bin", "obj", "debug", "release", "publish", "x86", "x64", "arm", "arm64", "anycpu", "any cpu" - }; - private static DirectoryInfo? FindProjectDirectory(DirectoryInfo dllDirectory) { - // Prefer anchoring on the nearest "bin" folder for standard .NET SDK layouts. var dir = dllDirectory; while (dir.Parent is not null) { @@ -197,29 +191,9 @@ public static TestResultSet Parse(FileInfo trxFile) return dir.Parent; dir = dir.Parent; } - - // Fallback for layouts that do not include "bin" (e.g. published artifact paths). - dir = dllDirectory; - while (dir.Parent is not null) - { - if (!IsKnownBuildOutputDir(dir.Name)) - return dir; - dir = dir.Parent; - } return dir; } - private static bool IsKnownBuildOutputDir(string name) => - KnownBuildOutputDirs.Contains(name) || IsDotNetTfm(name); - - // A .NET TFM directory name starts with "net" or "mono" and contains a digit - // (e.g. net10.0, netcoreapp3.1, net48, monoandroid10.0). - private static bool IsDotNetTfm(string name) => - name.Length > 4 && - (name.StartsWith("net", StringComparison.OrdinalIgnoreCase) || - name.StartsWith("mono", StringComparison.OrdinalIgnoreCase)) && - name.Any(char.IsDigit); - private static TestRun? DeserializeTestRun(Stream stream) {