From 416b597a97e003891c9407651574f88dc4f79c97 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:45:39 -0700 Subject: [PATCH 1/6] fix: Avoid HTML report CLI option clashes Fixes #6675 --- docs/docs/guides/html-report.md | 8 ++-- docs/docs/reference/command-line-flags.md | 7 +-- .../HtmlReporterCommandProvider.cs | 12 ++--- .../TestApplicationBuilderExtensions.cs | 14 ++---- tests/TUnit.Engine.Tests/HtmlReporterTests.cs | 44 +++++++++++++++++++ 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md index 16c02002948..ad49ee1216f 100644 --- a/docs/docs/guides/html-report.md +++ b/docs/docs/guides/html-report.md @@ -40,7 +40,7 @@ Running many test projects and want **one combined report instead of one per pro ### Custom Output Path ```bash -dotnet run -- --report-html-filename my-custom-report.html +dotnet run -- --tunit-report-html-filename my-custom-report.html ``` ### Disable Report Generation @@ -53,11 +53,9 @@ export TUNIT_DISABLE_HTML_REPORTER=true Accepts: `true`, `1`, `yes` (case-insensitive). -For version-controlled project configuration, set `context.Settings.Reporting.HtmlReportEnabled = false` in a `[Before(HookType.TestDiscovery)]` hook instead. +### Automatic Report Generation -### Deprecated: `--report-html` Flag - -The `--report-html` flag is deprecated since the report is now generated by default. Using it will show a deprecation warning but will not cause an error. +The deprecated `--report-html` enable flag has been removed because the report is generated automatically. Use `TUNIT_DISABLE_HTML_REPORTER=true` when you need to disable report generation. ## GitHub Actions Integration diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index ef2d60f2ca9..8b081a0331f 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -135,12 +135,7 @@ Please note that for the coverage and trx report, you need to install [additiona --output-json-prefix File name prefix for the JSON report produced by --output-json. - --report-html - (Deprecated) The HTML report is now generated by default. - Disable it with TUNIT_DISABLE_HTML_REPORTER or set - context.Settings.Reporting.HtmlReportEnabled = false. - - --report-html-filename + --tunit-report-html-filename Path for the HTML test report file (default: TestResults/{'{AssemblyName}'}-report.html). diff --git a/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs b/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs index 2f1b7fd7499..38c3e91b23b 100644 --- a/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs +++ b/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs @@ -6,8 +6,7 @@ namespace TUnit.Engine.CommandLineProviders; internal class HtmlReporterCommandProvider(IExtension extension) : ICommandLineOptionsProvider { - public const string ReportHtml = "report-html"; - public const string ReportHtmlFilename = "report-html-filename"; + public const string TUnitReportHtmlFilename = "tunit-report-html-filename"; public Task IsEnabledAsync() => extension.IsEnabledAsync(); @@ -24,12 +23,7 @@ public IReadOnlyCollection GetCommandLineOptions() return [ new CommandLineOption( - ReportHtml, - "Generate an HTML test report", - ArgumentArity.Zero, - false), - new CommandLineOption( - ReportHtmlFilename, + TUnitReportHtmlFilename, "Path for the HTML test report file (default: TestResults/{AssemblyName}-report.html)", ArgumentArity.ExactlyOne, false) @@ -40,7 +34,7 @@ public Task ValidateOptionArgumentsAsync( CommandLineOption commandOption, string[] arguments) { - if (commandOption.Name == ReportHtmlFilename && arguments.Length != 1) + if (commandOption.Name == TUnitReportHtmlFilename && arguments.Length != 1) { return ValidationResult.InvalidTask("A single output path must be provided for the HTML report"); } diff --git a/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index 2a69e0b2901..1a7284b5ba3 100644 --- a/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -80,7 +80,7 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) } // Set results directory as specified by --results-directory, - // so it can be used in the default output path if --report-html-filename is not provided + // so it can be used in the default output path if --junit-output-path is not provided junitReporter.SetResultsDirectory(serviceProvider.GetRequiredService().GetTestResultDirectory()); return junitReporter; @@ -95,15 +95,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) { var commandLineOptions = serviceProvider.GetRequiredService(); - // Deprecated: --report-html is now a no-op (reporter is always-on) - if (commandLineOptions.IsOptionSet(HtmlReporterCommandProvider.ReportHtml)) + if (commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.TUnitReportHtmlFilename, out var pathArgs)) { - Console.WriteLine("Warning: --report-html is deprecated. The HTML report is now generated by default. Use TUNIT_DISABLE_HTML_REPORTER=true to disable."); - } - - if (commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.ReportHtmlFilename, out var pathArgs)) - { - htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], HtmlReporterCommandProvider.ReportHtmlFilename)); + htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], HtmlReporterCommandProvider.TUnitReportHtmlFilename)); } // Inject the application-level message bus so PublishArtifactAsync works in @@ -111,7 +105,7 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) htmlReporter.SetMessageBus(serviceProvider.GetMessageBus()); // Set results directory as specified by --results-directory, - // so it can be used in the default output path if --report-html-filename is not provided + // so it can be used in the default output path if --tunit-report-html-filename is not provided htmlReporter.SetResultsDirectory(serviceProvider.GetRequiredService().GetTestResultDirectory()); return htmlReporter; diff --git a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs index 05123ed29cc..1fb51f4372e 100644 --- a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -3,11 +3,14 @@ using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.TestHost; using Shouldly; using TUnit.Core; using TUnit.Core.Enums; +using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Reporters; using TUnit.Engine.Reporters.Html; @@ -15,6 +18,47 @@ namespace TUnit.Engine.Tests; public class HtmlReporterTests { + [Test] + public void CommandProvider_Exposes_Only_TUnitSpecific_Filename_Option() + { + var provider = new HtmlReporterCommandProvider(new MockExtension()); + + var options = provider.GetCommandLineOptions(); + + options.Count.ShouldBe(1); + var option = options.Single(); + option.Name.ShouldBe("tunit-report-html-filename"); + option.Arity.ShouldBe(ArgumentArity.ExactlyOne); + options.Select(x => x.Name).ShouldNotContain("report-html"); + options.Select(x => x.Name).ShouldNotContain("report-html-filename"); + } + + [Test] + public async Task CommandProvider_Accepts_One_Filename_Argument() + { + var provider = new HtmlReporterCommandProvider(new MockExtension()); + var option = provider.GetCommandLineOptions().Single(); + + var result = await provider.ValidateOptionArgumentsAsync(option, ["report.html"]); + + result.IsValid.ShouldBeTrue(); + } + + [Test] + [Arguments(0)] + [Arguments(2)] + public async Task CommandProvider_Rejects_Filename_Argument_Count_Other_Than_One(int argumentCount) + { + var provider = new HtmlReporterCommandProvider(new MockExtension()); + var option = provider.GetCommandLineOptions().Single(); + var arguments = Enumerable.Repeat("report.html", argumentCount).ToArray(); + + var result = await provider.ValidateOptionArgumentsAsync(option, arguments); + + result.IsValid.ShouldBeFalse(); + result.ErrorMessage.ShouldBe("A single output path must be provided for the HTML report"); + } + [Test] public void HtmlReporter_Implements_IDataProducer() { From b92491119eb5256330075f2ca6371e849db5b9d5 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 28 Aug 2026 00:38:28 -0700 Subject: [PATCH 2/6] Select HTML report option names at build time from registered MTP hooks The previous approach renamed TUnit's HTML report options unconditionally, which breaks every existing TUnit-only user whose CI passes --report-html or --report-html-filename. Choose the names at build time instead. TUnit.Engine.props adds a target before _GenerateSelfRegisteredExtensionsFileInputCache that looks for Microsoft.Testing.Extensions.HtmlReport's well-known TestingPlatformBuilderHook (A6E2BCC3-9B4D-4B6D-8AE3-2C1E12A54F4D) and, when present, rewrites TUnit's hook TypeFullName to NamespacedHtmlReportTestingPlatformBuilderHook, which calls AddTUnit(HtmlCliMode.Namespaced). Modes: - Microsoft hook absent: --report-html and --report-html-filename, as today. - Microsoft hook present: neither conflicting name is registered; TUnit exposes --tunit-report-html-filename and Microsoft keeps its own. Both hooks are public and statically reachable, so MTP's generated SelfRegisteredExtensions.cs calls the selected one directly. No reflection, no UnsafeAccessor, no coupling to CommandLineManager internals, and it stays AOT-safe. --- Directory.Packages.props | 1 + docs/docs/guides/html-report.md | 22 ++++++++- docs/docs/reference/command-line-flags.md | 14 +++++- .../HtmlReporterCommandProvider.cs | 32 +++++++++++-- src/TUnit.Engine/Extensions/HtmlCliMode.cs | 7 +++ .../TestApplicationBuilderExtensions.cs | 19 ++++++-- .../Framework/TestingPlatformBuilderHook.cs | 8 ++++ src/TUnit.Engine/TUnit.Engine.props | 9 ++++ .../TUnit.Engine.Tests/HtmlReportCliTests.cs | 23 +++++++++ tests/TUnit.Engine.Tests/HtmlReporterTests.cs | 47 +++++++++++++------ .../TUnit.TestProject.csproj | 1 + 11 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 src/TUnit.Engine/Extensions/HtmlCliMode.cs create mode 100644 tests/TUnit.Engine.Tests/HtmlReportCliTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bc42be3347f..1d35ef2cad4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,7 @@ + diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md index ad49ee1216f..88fc42e52c9 100644 --- a/docs/docs/guides/html-report.md +++ b/docs/docs/guides/html-report.md @@ -39,6 +39,16 @@ Running many test projects and want **one combined report instead of one per pro ### Custom Output Path +When TUnit is the only HTML report provider, the existing option remains available: + +```bash +dotnet run -- --report-html-filename my-custom-report.html +``` + +When the `Microsoft.Testing.Extensions.HtmlReport` package is referenced, Microsoft owns the +`--report-html` and `--report-html-filename` options. TUnit automatically switches to its +namespaced filename option: + ```bash dotnet run -- --tunit-report-html-filename my-custom-report.html ``` @@ -53,9 +63,17 @@ export TUNIT_DISABLE_HTML_REPORTER=true Accepts: `true`, `1`, `yes` (case-insensitive). -### Automatic Report Generation +For version-controlled project configuration, set `context.Settings.Reporting.HtmlReportEnabled = false` in a `[Before(HookType.TestDiscovery)]` hook instead. + +### Deprecated: `--report-html` Flag + +Without `Microsoft.Testing.Extensions.HtmlReport`, TUnit continues to accept the deprecated +`--report-html` flag for compatibility. TUnit's report is generated automatically, so the flag +has no effect beyond displaying a deprecation warning. Use `TUNIT_DISABLE_HTML_REPORTER=true` +when you need to disable TUnit's report generation. -The deprecated `--report-html` enable flag has been removed because the report is generated automatically. Use `TUNIT_DISABLE_HTML_REPORTER=true` when you need to disable report generation. +When `Microsoft.Testing.Extensions.HtmlReport` is referenced, TUnit does not register either +legacy option; their behavior is provided by the Microsoft extension. ## GitHub Actions Integration diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index 8b081a0331f..25e88bab6a7 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -135,8 +135,20 @@ Please note that for the coverage and trx report, you need to install [additiona --output-json-prefix File name prefix for the JSON report produced by --output-json. + --report-html + Available when Microsoft.Testing.Extensions.HtmlReport is not referenced. + (Deprecated) The TUnit HTML report is now generated by default. + Disable it with TUNIT_DISABLE_HTML_REPORTER or set + context.Settings.Reporting.HtmlReportEnabled = false. + + --report-html-filename + Available when Microsoft.Testing.Extensions.HtmlReport is not referenced. + Path for the TUnit HTML test report file + (default: TestResults/{'{AssemblyName}'}-report.html). + --tunit-report-html-filename - Path for the HTML test report file + Available when Microsoft.Testing.Extensions.HtmlReport is referenced. + Path for the TUnit HTML test report file (default: TestResults/{'{AssemblyName}'}-report.html). --junit-output-path diff --git a/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs b/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs index 38c3e91b23b..f76e3febe22 100644 --- a/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs +++ b/src/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs @@ -1,13 +1,20 @@ using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.CommandLine; +using TUnit.Engine.Extensions; namespace TUnit.Engine.CommandLineProviders; -internal class HtmlReporterCommandProvider(IExtension extension) : ICommandLineOptionsProvider +internal class HtmlReporterCommandProvider(IExtension extension, HtmlCliMode htmlCliMode = HtmlCliMode.Default) : ICommandLineOptionsProvider { + public const string ReportHtml = "report-html"; + public const string ReportHtmlFilename = "report-html-filename"; public const string TUnitReportHtmlFilename = "tunit-report-html-filename"; + public string ReportHtmlFilenameOption => htmlCliMode == HtmlCliMode.Namespaced + ? TUnitReportHtmlFilename + : ReportHtmlFilename; + public Task IsEnabledAsync() => extension.IsEnabledAsync(); public string Uid => extension.Uid; @@ -20,13 +27,30 @@ internal class HtmlReporterCommandProvider(IExtension extension) : ICommandLineO public IReadOnlyCollection GetCommandLineOptions() { + if (htmlCliMode == HtmlCliMode.Namespaced) + { + return + [ + new CommandLineOption( + TUnitReportHtmlFilename, + "Path for the HTML test report file (default: TestResults/{AssemblyName}-report.html)", + ArgumentArity.ExactlyOne, + false), + ]; + } + return [ new CommandLineOption( - TUnitReportHtmlFilename, + ReportHtml, + "Generate an HTML test report", + ArgumentArity.Zero, + false), + new CommandLineOption( + ReportHtmlFilename, "Path for the HTML test report file (default: TestResults/{AssemblyName}-report.html)", ArgumentArity.ExactlyOne, - false) + false), ]; } @@ -34,7 +58,7 @@ public Task ValidateOptionArgumentsAsync( CommandLineOption commandOption, string[] arguments) { - if (commandOption.Name == TUnitReportHtmlFilename && arguments.Length != 1) + if (commandOption.Name == ReportHtmlFilenameOption && arguments.Length != 1) { return ValidationResult.InvalidTask("A single output path must be provided for the HTML report"); } diff --git a/src/TUnit.Engine/Extensions/HtmlCliMode.cs b/src/TUnit.Engine/Extensions/HtmlCliMode.cs new file mode 100644 index 00000000000..5e160ca7bcc --- /dev/null +++ b/src/TUnit.Engine/Extensions/HtmlCliMode.cs @@ -0,0 +1,7 @@ +namespace TUnit.Engine.Extensions; + +public enum HtmlCliMode +{ + Default, + Namespaced, +} diff --git a/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index 1a7284b5ba3..cc181f856f6 100644 --- a/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/src/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -15,7 +15,10 @@ namespace TUnit.Engine.Extensions; public static class TestApplicationBuilderExtensions { - public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) + public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) => + testApplicationBuilder.AddTUnit(HtmlCliMode.Default); + + public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder, HtmlCliMode htmlCliMode) { TUnitExtension extension = new(); @@ -26,7 +29,7 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) var junitReporterCommandProvider = new JUnitReporterCommandProvider(extension); var htmlReporter = new Reporters.Html.HtmlReporter(extension); - var htmlReporterCommandProvider = new HtmlReporterCommandProvider(extension); + var htmlReporterCommandProvider = new HtmlReporterCommandProvider(extension, htmlCliMode); htmlReporter.SetGitHubReporter(githubReporter); @@ -95,9 +98,15 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) { var commandLineOptions = serviceProvider.GetRequiredService(); - if (commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.TUnitReportHtmlFilename, out var pathArgs)) + // Deprecated: --report-html is now a no-op (reporter is always-on) + if (htmlCliMode == HtmlCliMode.Default && commandLineOptions.IsOptionSet(HtmlReporterCommandProvider.ReportHtml)) + { + Console.WriteLine("Warning: --report-html is deprecated. The HTML report is now generated by default. Use TUNIT_DISABLE_HTML_REPORTER=true to disable."); + } + + if (commandLineOptions.TryGetOptionArgumentList(htmlReporterCommandProvider.ReportHtmlFilenameOption, out var pathArgs)) { - htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], HtmlReporterCommandProvider.TUnitReportHtmlFilename)); + htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], htmlReporterCommandProvider.ReportHtmlFilenameOption)); } // Inject the application-level message bus so PublishArtifactAsync works in @@ -105,7 +114,7 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) htmlReporter.SetMessageBus(serviceProvider.GetMessageBus()); // Set results directory as specified by --results-directory, - // so it can be used in the default output path if --tunit-report-html-filename is not provided + // so it can be used in the default output path if the HTML report filename option is not provided htmlReporter.SetResultsDirectory(serviceProvider.GetRequiredService().GetTestResultDirectory()); return htmlReporter; diff --git a/src/TUnit.Engine/Framework/TestingPlatformBuilderHook.cs b/src/TUnit.Engine/Framework/TestingPlatformBuilderHook.cs index 083a88346eb..975f1818969 100644 --- a/src/TUnit.Engine/Framework/TestingPlatformBuilderHook.cs +++ b/src/TUnit.Engine/Framework/TestingPlatformBuilderHook.cs @@ -10,3 +10,11 @@ public static void AddExtensions( string[] _) => testApplicationBuilder.AddTUnit(); } + +public static class NamespacedHtmlReportTestingPlatformBuilderHook +{ + public static void AddExtensions( + ITestApplicationBuilder testApplicationBuilder, + string[] _) => + testApplicationBuilder.AddTUnit(HtmlCliMode.Namespaced); +} diff --git a/src/TUnit.Engine/TUnit.Engine.props b/src/TUnit.Engine/TUnit.Engine.props index 296dc59a7ac..18088f5db1a 100644 --- a/src/TUnit.Engine/TUnit.Engine.props +++ b/src/TUnit.Engine/TUnit.Engine.props @@ -44,4 +44,13 @@ + + + + TUnit.Engine.Framework.NamespacedHtmlReportTestingPlatformBuilderHook + + + + diff --git a/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs b/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs new file mode 100644 index 00000000000..e816b0a319a --- /dev/null +++ b/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs @@ -0,0 +1,23 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class HtmlReportCliTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Combined_Html_Report_Packages_Accept_TUnit_Namespaced_Option() + { + await RunTestsWithFilter( + "/*/*/BasicTests/SynchronousTest", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + ], + new RunOptions() + .WithArgument("--tunit-report-html-filename") + .WithArgument("tunit-report.html")); + } +} diff --git a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs index 1fb51f4372e..2a09349e637 100644 --- a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -11,6 +11,7 @@ using TUnit.Core; using TUnit.Core.Enums; using TUnit.Engine.CommandLineProviders; +using TUnit.Engine.Extensions; using TUnit.Engine.Reporters; using TUnit.Engine.Reporters.Html; @@ -19,16 +20,28 @@ namespace TUnit.Engine.Tests; public class HtmlReporterTests { [Test] - public void CommandProvider_Exposes_Only_TUnitSpecific_Filename_Option() + public void CommandProvider_DefaultMode_Exposes_Legacy_Options() { var provider = new HtmlReporterCommandProvider(new MockExtension()); var options = provider.GetCommandLineOptions(); + options.Count.ShouldBe(2); + options.Single(x => x.Name == "report-html").Arity.ShouldBe(ArgumentArity.Zero); + options.Single(x => x.Name == "report-html-filename").Arity.ShouldBe(ArgumentArity.ExactlyOne); + options.Select(x => x.Name).ShouldNotContain("tunit-report-html-filename"); + } + + [Test] + public void CommandProvider_NamespacedMode_Exposes_Only_TUnitSpecific_Filename_Option() + { + var provider = new HtmlReporterCommandProvider(new MockExtension(), HtmlCliMode.Namespaced); + + var options = provider.GetCommandLineOptions(); + options.Count.ShouldBe(1); - var option = options.Single(); - option.Name.ShouldBe("tunit-report-html-filename"); - option.Arity.ShouldBe(ArgumentArity.ExactlyOne); + options.Single().Name.ShouldBe("tunit-report-html-filename"); + options.Single().Arity.ShouldBe(ArgumentArity.ExactlyOne); options.Select(x => x.Name).ShouldNotContain("report-html"); options.Select(x => x.Name).ShouldNotContain("report-html-filename"); } @@ -36,12 +49,15 @@ public void CommandProvider_Exposes_Only_TUnitSpecific_Filename_Option() [Test] public async Task CommandProvider_Accepts_One_Filename_Argument() { - var provider = new HtmlReporterCommandProvider(new MockExtension()); - var option = provider.GetCommandLineOptions().Single(); + foreach (var mode in Enum.GetValues()) + { + var provider = new HtmlReporterCommandProvider(new MockExtension(), mode); + var option = provider.GetCommandLineOptions().Single(x => x.Arity == ArgumentArity.ExactlyOne); - var result = await provider.ValidateOptionArgumentsAsync(option, ["report.html"]); + var result = await provider.ValidateOptionArgumentsAsync(option, ["report.html"]); - result.IsValid.ShouldBeTrue(); + result.IsValid.ShouldBeTrue(); + } } [Test] @@ -49,14 +65,17 @@ public async Task CommandProvider_Accepts_One_Filename_Argument() [Arguments(2)] public async Task CommandProvider_Rejects_Filename_Argument_Count_Other_Than_One(int argumentCount) { - var provider = new HtmlReporterCommandProvider(new MockExtension()); - var option = provider.GetCommandLineOptions().Single(); - var arguments = Enumerable.Repeat("report.html", argumentCount).ToArray(); + foreach (var mode in Enum.GetValues()) + { + var provider = new HtmlReporterCommandProvider(new MockExtension(), mode); + var option = provider.GetCommandLineOptions().Single(x => x.Arity == ArgumentArity.ExactlyOne); + var arguments = Enumerable.Repeat("report.html", argumentCount).ToArray(); - var result = await provider.ValidateOptionArgumentsAsync(option, arguments); + var result = await provider.ValidateOptionArgumentsAsync(option, arguments); - result.IsValid.ShouldBeFalse(); - result.ErrorMessage.ShouldBe("A single output path must be provided for the HTML report"); + result.IsValid.ShouldBeFalse(); + result.ErrorMessage.ShouldBe("A single output path must be provided for the HTML report"); + } } [Test] diff --git a/tests/TUnit.TestProject/TUnit.TestProject.csproj b/tests/TUnit.TestProject/TUnit.TestProject.csproj index bb2f9a84854..a18335e32a7 100644 --- a/tests/TUnit.TestProject/TUnit.TestProject.csproj +++ b/tests/TUnit.TestProject/TUnit.TestProject.csproj @@ -30,6 +30,7 @@ + From 4df52235c868a669b010276b00beffdbbf9d018b Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:50:01 -0700 Subject: [PATCH 3/6] fix: scope the HTML report builder hook to the TUnit identity The TestingPlatformBuilderHook item metadata was set with Update=, which applies to every hook in the collection. In a combined package that produced eight TUnit hook registrations and the run aborted with "The test framework adapter factory has already been registered" before CLI parsing ran. Condition the metadata assignment on the TUnit identity instead, so the other hooks keep their own TypeFullName. Also import Microsoft.Testing.Platform.Extensions.CommandLine in the reporter tests, where ArgumentArity comes from; without it TUnit.Engine.Tests does not compile. --- src/TUnit.Engine/TUnit.Engine.props | 2 +- tests/TUnit.Engine.Tests/HtmlReporterTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/TUnit.Engine/TUnit.Engine.props b/src/TUnit.Engine/TUnit.Engine.props index 18088f5db1a..3b4b87928a0 100644 --- a/src/TUnit.Engine/TUnit.Engine.props +++ b/src/TUnit.Engine/TUnit.Engine.props @@ -47,7 +47,7 @@ - + TUnit.Engine.Framework.NamespacedHtmlReportTestingPlatformBuilderHook diff --git a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs index 2a09349e637..f47fc4619ab 100644 --- a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.CommandLine; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.TestHost; using Shouldly; From f969507562a79a2cdb97796d37363726052dd42f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:23:56 +0100 Subject: [PATCH 4/6] test(engine): use namespaced HTML option --- tests/TUnit.Engine.Tests/ReportingSettingsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs b/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs index 1ca19d4ecfe..9d2f34a94a6 100644 --- a/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs +++ b/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs @@ -15,7 +15,7 @@ public async Task Discovery_Hook_Can_Disable_Reporting(CancellationToken cancell try { var options = new RunOptions() - .WithArgument("--report-html-filename") + .WithArgument("--tunit-report-html-filename") .WithArgument(reportPath) .WithEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER", "false") .WithEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT", "false") From cab35f1340df8a7165fd18f48f1325b70ee6eae0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:47:25 +0100 Subject: [PATCH 5/6] test(engine): cover default HTML report path --- TUnit.Dev.slnx | 1 + TUnit.slnx | 1 + src/TUnit.Engine/TUnit.Engine.props | 2 + .../TUnit.Engine.Tests/HtmlReportCliTests.cs | 109 ++++++++++++++++++ .../TUnit.Engine.Tests.csproj | 1 + .../DefaultHtmlReportTests.cs | 9 ++ ...Unit.TestProject.HtmlReportDefaults.csproj | 15 +++ 7 files changed, 138 insertions(+) create mode 100644 tests/TUnit.TestProject.HtmlReportDefaults/DefaultHtmlReportTests.cs create mode 100644 tests/TUnit.TestProject.HtmlReportDefaults/TUnit.TestProject.HtmlReportDefaults.csproj diff --git a/TUnit.Dev.slnx b/TUnit.Dev.slnx index 87c935fb3c7..180772f47fb 100644 --- a/TUnit.Dev.slnx +++ b/TUnit.Dev.slnx @@ -66,6 +66,7 @@ + diff --git a/TUnit.slnx b/TUnit.slnx index ac3754c10a9..40c186ec05f 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -88,6 +88,7 @@ + diff --git a/src/TUnit.Engine/TUnit.Engine.props b/src/TUnit.Engine/TUnit.Engine.props index 3b4b87928a0..eae66ae0cfc 100644 --- a/src/TUnit.Engine/TUnit.Engine.props +++ b/src/TUnit.Engine/TUnit.Engine.props @@ -46,6 +46,8 @@ + TUnit.Engine.Framework.NamespacedHtmlReportTestingPlatformBuilderHook diff --git a/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs b/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs index e816b0a319a..4a1839a641e 100644 --- a/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReportCliTests.cs @@ -1,3 +1,5 @@ +using CliWrap; +using CliWrap.Buffered; using Shouldly; using TUnit.Engine.Tests.Enums; @@ -21,3 +23,110 @@ await RunTestsWithFilter( .WithArgument("tunit-report.html")); } } + +public class DefaultHtmlReportCliTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TUnit_Only_Project_Accepts_Legacy_Options(bool reflection) + { + var tempDirectory = CreateTempDirectory(); + var reportPath = Path.Combine(tempDirectory, "custom-report.html"); + + try + { + var result = await RunTUnitOnlyProject( + tempDirectory, + reflection, + "--report-html", + "--report-html-filename", + reportPath); + + AssertSuccessful(result); + File.Exists(reportPath).ShouldBeTrue(); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TUnit_Only_Project_Uses_Default_Output_Path(bool reflection) + { + var tempDirectory = CreateTempDirectory(); + + try + { + var result = await RunTUnitOnlyProject(tempDirectory, reflection); + + AssertSuccessful(result); + + var reports = Directory.GetFiles(tempDirectory, "*-report.html"); + reports.Length.ShouldBe(1); + Path.GetFileName(reports[0]).ShouldStartWith("TUnit.TestProject.HtmlReportDefaults-"); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"tunit-html-defaults-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static Task RunTUnitOnlyProject( + string resultsDirectory, + bool reflection, + params string[] htmlArguments) + { + var testProject = Sourcy.DotNet.Projects.TUnit_TestProject_HtmlReportDefaults; + List arguments = + [ + "run", + "--no-build", + "--project", testProject.FullName, + "--framework", "net10.0", + "--configuration", "Release", + "--", + "--treenode-filter", "/*/*/DefaultHtmlReportTests/Pass", + "--results-directory", resultsDirectory, + ..htmlArguments, + ]; + + if (reflection) + { + arguments.Add("--reflection"); + } + + return Cli.Wrap("dotnet") + .WithArguments(arguments) + .WithWorkingDirectory(testProject.DirectoryName!) + .WithEnvironmentVariables(new Dictionary + { + ["TUNIT_DISABLE_HTML_REPORTER"] = "false", + ["TUNIT_DISABLE_JSON_REPORT"] = "true", + ["TUNIT_DISABLE_ARTIFACT_UPLOAD"] = "true", + }) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(); + } + + private static void AssertSuccessful(BufferedCommandResult result) + { + result.ExitCode.ShouldBe(0, $""" + Standard output: + {result.StandardOutput} + + Standard error: + {result.StandardError} + """); + } +} diff --git a/tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj b/tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj index a16423b43f1..eae576b9440 100644 --- a/tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj +++ b/tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/TUnit.TestProject.HtmlReportDefaults/DefaultHtmlReportTests.cs b/tests/TUnit.TestProject.HtmlReportDefaults/DefaultHtmlReportTests.cs new file mode 100644 index 00000000000..f577e26add0 --- /dev/null +++ b/tests/TUnit.TestProject.HtmlReportDefaults/DefaultHtmlReportTests.cs @@ -0,0 +1,9 @@ +namespace TUnit.TestProject.HtmlReportDefaults; + +public class DefaultHtmlReportTests +{ + [Test] + public void Pass() + { + } +} diff --git a/tests/TUnit.TestProject.HtmlReportDefaults/TUnit.TestProject.HtmlReportDefaults.csproj b/tests/TUnit.TestProject.HtmlReportDefaults/TUnit.TestProject.HtmlReportDefaults.csproj new file mode 100644 index 00000000000..9f7b9c9c358 --- /dev/null +++ b/tests/TUnit.TestProject.HtmlReportDefaults/TUnit.TestProject.HtmlReportDefaults.csproj @@ -0,0 +1,15 @@ + + + + + + net10.0 + + + + + + + + + From e872e21ca12915b887463849f00fadaf63973bf5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:19:09 +0100 Subject: [PATCH 6/6] ci: build HTML report fixture in Release --- TUnit.CI.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx index d5cd80e607a..a0688c90109 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -112,6 +112,7 @@ +