From a46f2a8f18740f7d38e0dac8af89d339c5149729 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 17 May 2026 22:28:21 +0100 Subject: [PATCH] fix(html-report): extract categories using MTP Key=name, Value=empty convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HtmlReporter.ExtractTestResult` was checking `IsNullOrEmpty(meta.Key)` to distinguish categories from key/value custom properties, but Microsoft's own VSTestBridge convention (and TUnit's own `TestExtensions`) emits categories as `TestMetadataProperty(name, "")` — name in Key, Value empty. The check was inverted, so every `[Category]` silently landed in `customProperties` as `{key:"Async", value:""}` instead of populating the `categories` array. As a result the HTML report's category-pill UI (`buildCatPills()` / `#categoryPills`) had nothing to render and stayed hidden, even for projects that tag every test class. Switch the field check: `IsNullOrEmpty(meta.Value)` → category (name in Key), otherwise → custom property. Matches the MTP/VSTestBridge convention already used elsewhere in TUnit (`TestFilterService.cs:248`, `TestExtensions.cs:71`) and lets the existing pill UI shipped in #5486 finally light up. Adds a unit test pinning the split between the two TestMetadataProperty shapes so the extraction can't silently regress again. --- TUnit.Engine.Tests/HtmlReporterTests.cs | 37 +++++++++++++++++++++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 9 +++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index e5faada495..0006979d1f 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -262,6 +262,43 @@ public void GenerateHtml_RoundTrips_ClassTimeline_CustomProperty_OnTest() embedded.ShouldContain("\"value\":\"FullExecution\""); } + [Test] + public void ExtractTestResult_SortsTestMetadataProperty_Into_Categories_And_CustomProperties() + { + // Regression: Microsoft.Testing.Platform's VSTestBridge convention emits categories as + // TestMetadataProperty(name, "") — the category name lives in Key, Value is empty. + // Traits/custom properties use the (key, value) form with a non-empty Value. + // Earlier code keyed off IsNullOrEmpty(Key), which inverted both branches and silently + // misclassified every category as a custom property — leaving the HTML report's + // category-pill UI permanently empty. + var node = new TestNode + { + Uid = new TestNodeUid("extract-1"), + DisplayName = "Test", + Properties = new PropertyBag( + PassedTestNodeStateProperty.CachedInstance, + new TestMethodIdentifierProperty( + @namespace: "TestNamespace", + assemblyFullName: "TestAssembly", + typeName: "SampleTests", + methodName: "Test", + parameterTypeFullNames: [], + returnTypeFullName: "System.Void", + methodArity: 0), + new TestMetadataProperty("Async", string.Empty), + new TestMetadataProperty("Integration", string.Empty), + new TestMetadataProperty("Owner", "TeamA")) + }; + + var result = HtmlReporter.ExtractTestResult("extract-1", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null); + + result.Categories.ShouldBe(["Async", "Integration"], ignoreOrder: true); + result.CustomProperties.ShouldNotBeNull(); + result.CustomProperties!.Length.ShouldBe(1); + result.CustomProperties[0].Key.ShouldBe("Owner"); + result.CustomProperties[0].Value.ShouldBe("TeamA"); + } + private static ReportTestResult CreateTestResultWithStartTime(string displayName, string? startTime) => new() { Id = displayName, diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index ecb724b4ea..922bff23e8 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -484,7 +484,7 @@ private static DateTimeOffset ParseStartTimeForSort(string? raw) : DateTimeOffset.MaxValue; } - private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds) + internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds) { IProperty? stateProperty = null; TestMethodIdentifierProperty? testMethodIdentifier = null; @@ -518,10 +518,13 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo stdErr = TruncateOutput(e.StandardError); break; case TestMetadataProperty meta: - if (string.IsNullOrEmpty(meta.Key)) + // MTP convention (matches Microsoft.Testing.Extensions.VSTestBridge): categories are + // emitted as TestMetadataProperty(category, "") — name in Key, empty Value. Traits/ + // custom properties use (key, value) with a non-empty Value. + if (string.IsNullOrEmpty(meta.Value)) { categories ??= []; - categories.Add(meta.Value); + categories.Add(meta.Key); } else {