diff --git a/TestFx.slnx b/TestFx.slnx
index f009643b2b..01bd46c018 100644
--- a/TestFx.slnx
+++ b/TestFx.slnx
@@ -41,6 +41,7 @@
+
diff --git a/docs/images/html-report/duplicate-uid.png b/docs/images/html-report/duplicate-uid.png
new file mode 100644
index 0000000000..2012283b44
Binary files /dev/null and b/docs/images/html-report/duplicate-uid.png differ
diff --git a/docs/images/html-report/group-by-class.png b/docs/images/html-report/group-by-class.png
new file mode 100644
index 0000000000..fa14c9f2e4
Binary files /dev/null and b/docs/images/html-report/group-by-class.png differ
diff --git a/docs/images/html-report/group-by-duration.png b/docs/images/html-report/group-by-duration.png
new file mode 100644
index 0000000000..01e796fb00
Binary files /dev/null and b/docs/images/html-report/group-by-duration.png differ
diff --git a/docs/images/html-report/group-by-trait-dark.png b/docs/images/html-report/group-by-trait-dark.png
new file mode 100644
index 0000000000..3964e7e9d2
Binary files /dev/null and b/docs/images/html-report/group-by-trait-dark.png differ
diff --git a/docs/images/html-report/overview.png b/docs/images/html-report/overview.png
new file mode 100644
index 0000000000..e6bbc39c9e
Binary files /dev/null and b/docs/images/html-report/overview.png differ
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/BannedSymbols.txt
new file mode 100644
index 0000000000..64ef236c50
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/BannedSymbols.txt
@@ -0,0 +1,9 @@
+P:System.DateTime.Now; Use 'IClock' instead
+P:System.DateTime.UtcNow; Use 'IClock' instead
+M:System.Threading.Tasks.Task.Run(System.Action); Use 'ITask' instead
+M:System.Threading.Tasks.Task.WhenAll(System.Threading.Tasks.Task[]); Use 'ITask' instead
+M:System.Threading.Tasks.Task.WhenAll(System.Collections.Generic.IEnumerable{System.Threading.Tasks.Task}); Use 'ITask' instead
+M:System.String.IsNullOrEmpty(System.String); Use 'RoslynString.IsNullOrEmpty' instead
+M:System.String.IsNullOrWhiteSpace(System.String); Use 'RoslynString.IsNullOrWhiteSpace' instead
+M:System.Diagnostics.Debug.Assert(System.Boolean); Use 'RoslynDebug.Assert' instead
+M:System.Diagnostics.Debug.Assert(System.Boolean,System.String); Use 'RoslynDebug.Assert' instead
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/CapturedTestResult.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/CapturedTestResult.cs
new file mode 100644
index 0000000000..49df78bdd0
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/CapturedTestResult.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Extensions.HtmlReport;
+
+// Minimal capped-size projection of a TestNodeUpdateMessage. The consumer projects
+// each message into this DTO immediately so that we don't retain entire test nodes
+// (and their potentially huge stdout/stderr/stack trace strings) in memory for the
+// whole session. All variable-length text fields are already truncated at this point
+// so the engine doesn't need to truncate again.
+internal sealed class CapturedTestResult
+{
+ public required string Uid { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ public required string Outcome { get; init; }
+
+ public required TimeSpan Duration { get; init; }
+
+ public DateTimeOffset? StartTime { get; init; }
+
+ public DateTimeOffset? EndTime { get; init; }
+
+ public string? ClassName { get; init; }
+
+ public string? MethodName { get; init; }
+
+ public string? ErrorMessage { get; init; }
+
+ public string? ExceptionType { get; init; }
+
+ public string? StackTrace { get; init; }
+
+ public string? StandardOutput { get; init; }
+
+ public string? StandardError { get; init; }
+
+ public IReadOnlyList>? Traits { get; init; }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs
new file mode 100644
index 0000000000..3985538152
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs
@@ -0,0 +1,463 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Extensions.HtmlReport.Resources;
+using Microsoft.Testing.Platform.CommandLine;
+using Microsoft.Testing.Platform.Configurations;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Helpers;
+using Microsoft.Testing.Platform.Services;
+
+namespace Microsoft.Testing.Extensions.HtmlReport;
+
+internal sealed class HtmlReportEngine
+{
+ private const string TemplateResourceName = "Microsoft.Testing.Extensions.HtmlReport.Templates.report-template.html";
+ private const string DataPlaceholder = "/*__MTP_DATA__*/null";
+ private const string GeneratorVersionPlaceholder = "__MTP_GENERATOR_VERSION__";
+
+ private readonly IFileSystem _fileSystem;
+ private readonly ITestApplicationModuleInfo _testApplicationModuleInfo;
+ private readonly IEnvironment _environment;
+ private readonly ICommandLineOptions _commandLineOptions;
+ private readonly IConfiguration _configuration;
+ private readonly IClock _clock;
+ private readonly ITestFramework _testFramework;
+ private readonly DateTimeOffset _testStartTime;
+ private readonly int _exitCode;
+ private readonly CancellationToken _cancellationToken;
+
+ public HtmlReportEngine(
+ IFileSystem fileSystem,
+ ITestApplicationModuleInfo testApplicationModuleInfo,
+ IEnvironment environment,
+ ICommandLineOptions commandLineOptions,
+ IConfiguration configuration,
+ IClock clock,
+ ITestFramework testFramework,
+ DateTimeOffset testStartTime,
+ int exitCode,
+ CancellationToken cancellationToken)
+ {
+ _fileSystem = fileSystem;
+ _testApplicationModuleInfo = testApplicationModuleInfo;
+ _environment = environment;
+ _commandLineOptions = commandLineOptions;
+ _configuration = configuration;
+ _clock = clock;
+ _testFramework = testFramework;
+ _testStartTime = testStartTime;
+ _exitCode = exitCode;
+ _cancellationToken = cancellationToken;
+ }
+
+ public Task<(string FileName, string? Warning)> GenerateReportAsync(CapturedTestResult[] results)
+ => GenerateReportCoreAsync(results, _clock.UtcNow);
+
+ private async Task<(string FileName, string? Warning)> GenerateReportCoreAsync(CapturedTestResult[] results, DateTimeOffset finishTime)
+ {
+ _cancellationToken.ThrowIfCancellationRequested();
+
+ bool fileNameExplicitlyProvided = _commandLineOptions.TryGetOptionArgumentList(
+ HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName,
+ out string[]? providedFileName);
+
+ string fileName = fileNameExplicitlyProvided
+ ? providedFileName![0]
+ : BuildDefaultFileName(finishTime);
+
+ string outputDirectory = _configuration.GetTestResultDirectory();
+ string finalPath = Path.Combine(outputDirectory, fileName);
+
+ string template = LoadTemplate();
+ string json = BuildJson(results, finishTime);
+
+ string html = template
+ .Replace(GeneratorVersionPlaceholder, ExtensionVersion.DefaultSemVer)
+ .Replace(DataPlaceholder, json);
+
+ byte[] bytes = Encoding.UTF8.GetBytes(html);
+
+ return await WriteWithRetryAsync(finalPath, bytes, fileNameExplicitlyProvided).ConfigureAwait(false);
+ }
+
+ private async Task<(string FileName, string? Warning)> WriteWithRetryAsync(string finalPath, byte[] bytes, bool fileNameExplicitlyProvided)
+ {
+ // Explicit file names: use FileMode.Create (overwrite). Default-generated file
+ // names: use FileMode.CreateNew but retry with disambiguating suffixes when the
+ // file already exists, so concurrent runs (or two runs within the same second
+ // sharing the result directory) don't fail with IOException.
+ if (fileNameExplicitlyProvided)
+ {
+ bool willOverwrite = _fileSystem.ExistFile(finalPath);
+ await WriteAsync(finalPath, FileMode.Create, bytes).ConfigureAwait(false);
+ return (
+ finalPath,
+ willOverwrite
+ ? string.Format(CultureInfo.InvariantCulture, ExtensionResources.HtmlReportFileExistsAndWillBeOverwritten, finalPath)
+ : null);
+ }
+
+ DateTimeOffset firstTry = _clock.UtcNow;
+ string directory = Path.GetDirectoryName(finalPath) ?? string.Empty;
+ string baseName = Path.GetFileNameWithoutExtension(finalPath);
+ string extension = Path.GetExtension(finalPath);
+ string candidate = finalPath;
+ int attempt = 0;
+
+ while (true)
+ {
+ _cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ await WriteAsync(candidate, FileMode.CreateNew, bytes).ConfigureAwait(false);
+ return (candidate, null);
+ }
+ catch (IOException)
+ {
+ if (_clock.UtcNow - firstTry > TimeSpan.FromSeconds(5))
+ {
+ throw;
+ }
+
+ attempt++;
+ candidate = Path.Combine(directory, $"{baseName}_{attempt}{extension}");
+ }
+ }
+ }
+
+ private async Task WriteAsync(string path, FileMode mode, byte[] bytes)
+ {
+ // Note that we need to dispose the IFileStream, not the inner stream.
+ // IFileStream implementations will be responsible to dispose their inner stream.
+ using IFileStream stream = _fileSystem.NewFileStream(path, mode);
+#if NETCOREAPP
+ await stream.Stream.WriteAsync(bytes.AsMemory(), _cancellationToken).ConfigureAwait(false);
+#else
+ await stream.Stream.WriteAsync(bytes, 0, bytes.Length, _cancellationToken).ConfigureAwait(false);
+#endif
+ }
+
+ private string BuildDefaultFileName(DateTimeOffset finishTime)
+ {
+ string user = _environment.GetEnvironmentVariable("UserName")
+ ?? _environment.GetEnvironmentVariable("USER")
+ ?? "user";
+ string raw = $"{user}_{_environment.MachineName}_{finishTime:yyyy-MM-dd_HH_mm_ss}.html";
+ return ReplaceInvalidFileNameChars(raw);
+ }
+
+ private static string ReplaceInvalidFileNameChars(string fileName)
+ {
+ var sb = new StringBuilder(fileName.Length);
+ char[] invalid = Path.GetInvalidFileNameChars();
+ foreach (char c in fileName)
+ {
+ sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c);
+ }
+
+ return sb.ToString();
+ }
+
+ private static string LoadTemplate()
+ {
+ Assembly assembly = typeof(HtmlReportEngine).Assembly;
+ using Stream stream = assembly.GetManifestResourceStream(TemplateResourceName)
+ ?? throw ApplicationStateGuard.Unreachable();
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private string BuildJson(CapturedTestResult[] results, DateTimeOffset finishTime)
+ {
+ int passed = 0;
+ int failed = 0;
+ int skipped = 0;
+ int timedout = 0;
+ int errored = 0;
+ TimeSpan totalDuration = TimeSpan.Zero;
+
+ // First pass: count how many entries each UID is going to produce so we can
+ // annotate rows that share a UID with "attemptIndex"/"attemptOf". This lets the
+ // UI surface frameworks that emit multiple terminal results for the same UID
+ // (parameterized rows, in-process retries, broken UID generators, etc.) without
+ // silently dropping any data.
+ Dictionary countByUid = [];
+ foreach (CapturedTestResult r in results)
+ {
+ countByUid[r.Uid] = countByUid.TryGetValue(r.Uid, out int existing) ? existing + 1 : 1;
+ }
+
+ Dictionary emittedByUid = [];
+
+ StringBuilder sb = new(8 * 1024);
+ sb.Append('{');
+ AppendStringPair(sb, "schemaVersion", "1");
+ sb.Append(',');
+ AppendStringPair(sb, "generator", "Microsoft.Testing.Extensions.HtmlReport");
+ sb.Append(',');
+ AppendStringPair(sb, "generatorVersion", ExtensionVersion.DefaultSemVer);
+ sb.Append(',');
+ AppendStringPair(sb, "testApplication", _testApplicationModuleInfo.GetCurrentTestApplicationFullPath());
+ sb.Append(',');
+ AppendStringPair(sb, "machineName", _environment.MachineName);
+ sb.Append(',');
+ AppendStringPair(sb, "userName", _environment.GetEnvironmentVariable("UserName") ?? _environment.GetEnvironmentVariable("USER") ?? string.Empty);
+ sb.Append(',');
+ AppendStringPair(sb, "framework", _testFramework.DisplayName);
+ sb.Append(',');
+ AppendStringPair(sb, "frameworkUid", _testFramework.Uid);
+ sb.Append(',');
+ AppendStringPair(sb, "frameworkVersion", _testFramework.Version);
+ sb.Append(',');
+ AppendStringPair(sb, "startTime", _testStartTime.ToString("O", CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendStringPair(sb, "endTime", finishTime.ToString("O", CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "exitCode", _exitCode.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendKey(sb, "tests");
+ sb.Append('[');
+
+ bool first = true;
+ for (int i = 0; i < results.Length; i++)
+ {
+ CapturedTestResult r = results[i];
+ if (!first)
+ {
+ sb.Append(',');
+ }
+
+ first = false;
+
+ CountOutcome(r.Outcome, ref passed, ref failed, ref skipped, ref timedout, ref errored);
+ totalDuration += r.Duration;
+
+ int attemptOf = countByUid[r.Uid];
+ int attemptIndex = emittedByUid.TryGetValue(r.Uid, out int alreadyEmitted) ? alreadyEmitted + 1 : 1;
+ emittedByUid[r.Uid] = attemptIndex;
+
+ sb.Append('{');
+ AppendNumberPair(sb, "rowKey", i.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendStringPair(sb, "uid", r.Uid);
+ sb.Append(',');
+ AppendStringPair(sb, "displayName", r.DisplayName);
+ sb.Append(',');
+ AppendStringPair(sb, "outcome", r.Outcome);
+ sb.Append(',');
+ AppendNumberPair(sb, "durationMs", r.Duration.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture));
+
+ if (attemptOf > 1)
+ {
+ sb.Append(',');
+ AppendNumberPair(sb, "attemptIndex", attemptIndex.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "attemptOf", attemptOf.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (r.StartTime is { } startTime)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "startTime", startTime.ToString("O", CultureInfo.InvariantCulture));
+ }
+
+ if (r.EndTime is { } endTime)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "endTime", endTime.ToString("O", CultureInfo.InvariantCulture));
+ }
+
+ if (r.ClassName is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "className", r.ClassName);
+ }
+
+ if (r.MethodName is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "methodName", r.MethodName);
+ }
+
+ if (r.Traits is { Count: > 0 })
+ {
+ sb.Append(',');
+ AppendKey(sb, "traits");
+ sb.Append('[');
+ for (int t = 0; t < r.Traits.Count; t++)
+ {
+ if (t > 0)
+ {
+ sb.Append(',');
+ }
+
+ KeyValuePair trait = r.Traits[t];
+ sb.Append('{');
+ AppendStringPair(sb, "key", trait.Key);
+ sb.Append(',');
+ AppendStringPair(sb, "value", trait.Value);
+ sb.Append('}');
+ }
+
+ sb.Append(']');
+ }
+
+ if (r.ErrorMessage is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "errorMessage", r.ErrorMessage);
+ }
+
+ if (r.ExceptionType is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "exceptionType", r.ExceptionType);
+ }
+
+ if (r.StackTrace is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "stackTrace", r.StackTrace);
+ }
+
+ if (r.StandardOutput is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "standardOutput", r.StandardOutput);
+ }
+
+ if (r.StandardError is not null)
+ {
+ sb.Append(',');
+ AppendStringPair(sb, "standardError", r.StandardError);
+ }
+
+ sb.Append('}');
+ }
+
+ sb.Append("],");
+
+ AppendKey(sb, "summary");
+ sb.Append('{');
+ AppendNumberPair(sb, "total", (passed + failed + skipped + timedout + errored).ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "passed", passed.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "failed", failed.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "skipped", skipped.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "timedOut", timedout.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "errored", errored.ToString(CultureInfo.InvariantCulture));
+ sb.Append(',');
+ AppendNumberPair(sb, "totalDurationMs", totalDuration.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture));
+ sb.Append('}');
+
+ sb.Append('}');
+
+ return sb.ToString();
+ }
+
+ private static void CountOutcome(string outcome, ref int passed, ref int failed, ref int skipped, ref int timedout, ref int errored)
+ {
+ switch (outcome)
+ {
+ case "passed": passed++; break;
+ case "failed": failed++; break;
+ case "skipped": skipped++; break;
+ case "timedOut": timedout++; break;
+ case "errored": errored++; break;
+ default: throw ApplicationStateGuard.Unreachable();
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Minimal HTML-safe JSON writer.
+ // We can't use System.Text.Json because the extension targets
+ // netstandard2.0 (where it isn't part of the platform's reference set).
+ // The output is a plain JSON document but every character that could
+ // close the embedding
+
+