diff --git a/.github/actions/execute-pipeline/action.yml b/.github/actions/execute-pipeline/action.yml index 50c32f7793..c53f2b692b 100644 --- a/.github/actions/execute-pipeline/action.yml +++ b/.github/actions/execute-pipeline/action.yml @@ -38,4 +38,6 @@ runs: DOTNET_ENVIRONMENT: ${{ inputs.environment }} NuGet__ApiKey: ${{ inputs.nuget-apikey }} NuGet__ShouldPublish: ${{ inputs.publish-packages }} - NET_VERSION: ${{ inputs.netversion }} \ No newline at end of file + NET_VERSION: ${{ inputs.netversion }} + ACTIONS_RUNTIME_TOKEN: ${{ env.ACTIONS_RUNTIME_TOKEN }} + ACTIONS_RESULTS_URL: ${{ env.ACTIONS_RESULTS_URL }} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e77888ed59..b1c6a2833a 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -122,6 +122,13 @@ jobs: - name: Publish AOT run: dotnet publish TUnit.TestProject/TUnit.TestProject.csproj -c Release --use-current-runtime -p:Aot=true -o TESTPROJECT_AOT --framework net10.0 + - name: Expose GitHub Actions Runtime + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']); + core.exportVariable('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']); + - name: Run Pipeline uses: ./.github/actions/execute-pipeline with: diff --git a/TUnit.Core/Interfaces/ITestOutput.cs b/TUnit.Core/Interfaces/ITestOutput.cs index 0562502e4d..2546cf1674 100644 --- a/TUnit.Core/Interfaces/ITestOutput.cs +++ b/TUnit.Core/Interfaces/ITestOutput.cs @@ -24,6 +24,7 @@ public interface ITestOutput /// Gets the collection of timing measurements recorded during test execution. /// Useful for performance profiling and identifying bottlenecks. /// + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] IReadOnlyCollection Timings { get; } /// @@ -37,6 +38,7 @@ public interface ITestOutput /// Thread-safe for concurrent calls. /// /// The timing information to record + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] void RecordTiming(Timing timing); /// diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index db41cba80b..afde0f3f6e 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -10,6 +10,7 @@ namespace TUnit.Core; /// public partial class TestContext { +#pragma warning disable CS0618 // Obsolete Timing API — internal backing for interface implementation // Internal backing fields and properties internal ConcurrentBag Timings { get; } = []; private readonly ConcurrentBag _artifactsBag = new(); @@ -20,12 +21,15 @@ public partial class TestContext TextWriter ITestOutput.StandardOutput => OutputWriter; TextWriter ITestOutput.ErrorOutput => ErrorOutputWriter; IReadOnlyCollection ITestOutput.Timings => Timings; +#pragma warning restore CS0618 IReadOnlyCollection ITestOutput.Artifacts => Artifacts; +#pragma warning disable CS0618 // Obsolete Timing API — internal backing for interface implementation void ITestOutput.RecordTiming(Timing timing) { Timings.Add(timing); } +#pragma warning restore CS0618 void ITestOutput.AttachArtifact(Artifact artifact) { diff --git a/TUnit.Core/Timing.cs b/TUnit.Core/Timing.cs index 71e1797f85..096c9f6a78 100644 --- a/TUnit.Core/Timing.cs +++ b/TUnit.Core/Timing.cs @@ -1,5 +1,6 @@ namespace TUnit.Core; +[Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] public record Timing(string StepName, DateTimeOffset Start, DateTimeOffset End) { public TimeSpan Duration => End - Start; diff --git a/TUnit.Engine/Configuration/EnvironmentConstants.cs b/TUnit.Engine/Configuration/EnvironmentConstants.cs index ee4aed3ea6..ee293a94fa 100644 --- a/TUnit.Engine/Configuration/EnvironmentConstants.cs +++ b/TUnit.Engine/Configuration/EnvironmentConstants.cs @@ -5,6 +5,7 @@ internal static class EnvironmentConstants // TUnit-specific: Reporters public const string DisableGithubReporter = "TUNIT_DISABLE_GITHUB_REPORTER"; public const string DisableJUnitReporter = "TUNIT_DISABLE_JUNIT_REPORTER"; + public const string DisableHtmlReporter = "TUNIT_DISABLE_HTML_REPORTER"; public const string EnableJUnitReporter = "TUNIT_ENABLE_JUNIT_REPORTER"; public const string GitHubReporterStyle = "TUNIT_GITHUB_REPORTER_STYLE"; @@ -28,4 +29,16 @@ internal static class EnvironmentConstants public const string GitHubStepSummary = "GITHUB_STEP_SUMMARY"; public const string GitLabCi = "GITLAB_CI"; public const string CiServer = "CI_SERVER"; + + // GitHub Actions runtime (for artifact upload) + public const string ActionsRuntimeToken = "ACTIONS_RUNTIME_TOKEN"; + public const string ActionsResultsUrl = "ACTIONS_RESULTS_URL"; + public const string GitHubRepository = "GITHUB_REPOSITORY"; + public const string GitHubRunId = "GITHUB_RUN_ID"; + + // GitHub Actions context (for CI metadata in reports) + public const string GitHubSha = "GITHUB_SHA"; + public const string GitHubRef = "GITHUB_REF"; + public const string GitHubHeadRef = "GITHUB_HEAD_REF"; + public const string GitHubEventName = "GITHUB_EVENT_NAME"; } diff --git a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index 6726b9cbe8..29731c3b53 100644 --- a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -24,7 +24,7 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) var junitReporter = new JUnitReporter(extension); var junitReporterCommandProvider = new JUnitReporterCommandProvider(extension); - var htmlReporter = new HtmlReporter(extension); + var htmlReporter = new Reporters.Html.HtmlReporter(extension); var htmlReporterCommandProvider = new HtmlReporterCommandProvider(extension); testApplicationBuilder.RegisterTestFramework( @@ -87,16 +87,15 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) { var commandLineOptions = serviceProvider.GetRequiredService(); - // Enable if --report-html flag is set or --report-html-filename is provided - if (commandLineOptions.IsOptionSet(HtmlReporterCommandProvider.ReportHtml) - || commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.ReportHtmlFilename, out _)) + // Deprecated: --report-html is now a no-op (reporter is always-on) + if (commandLineOptions.IsOptionSet(HtmlReporterCommandProvider.ReportHtml)) { - htmlReporter.Enable(); + 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(pathArgs[0]); + htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], HtmlReporterCommandProvider.ReportHtmlFilename)); } return htmlReporter; diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index f7a75b01b8..602a450218 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -291,6 +291,7 @@ private static bool IsTrxEnabled(TestContext testContext) return _cachedIsTrxEnabled.Value; } +#pragma warning disable CS0618 // Obsolete Timing API — still needed for TimingProperty reporting private static TimingProperty GetTimingProperty(TestContext testContext, DateTimeOffset overallStart) { if (overallStart == default(DateTimeOffset)) @@ -309,6 +310,7 @@ private static TimingProperty GetTimingProperty(TestContext testContext, DateTim return new TimingProperty(new TimingInfo(overallStart, end, end - overallStart), stepTimings); } +#pragma warning restore CS0618 private static IEnumerable GetTrxMessages(TestContext testContext, string? standardOutput, string? standardError) { diff --git a/TUnit.Engine/Helpers/Timings.cs b/TUnit.Engine/Helpers/Timings.cs deleted file mode 100644 index 9eab376afd..0000000000 --- a/TUnit.Engine/Helpers/Timings.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.Testing.Platform.Extensions.Messages; -using TUnit.Core; - -namespace TUnit.Engine.Helpers; - -internal static class Timings -{ - public static void Record(string name, TestContext context, Action action) - { - var start = DateTimeOffset.Now; - - try - { - action(); - } - finally - { - var end = DateTimeOffset.Now; - - // ConcurrentBag is lock-free and thread-safe - context.Timings.Add(new Timing(name, start, end)); - } - } - - public static Task Record(string name, TestContext context, Func action) - { - return Record(name, context, () => new ValueTask(action())); - } - - public static async Task Record(string name, TestContext context, Func action) - { - var start = DateTimeOffset.Now; - - try - { - await action(); - } - finally - { - var end = DateTimeOffset.Now; - - // ConcurrentBag is lock-free and thread-safe - context.Timings.Add(new Timing(name, start, end)); - } - } - - public static TimingProperty GetTimingProperty(TestContext testContext, DateTimeOffset overallStart) - { - var end = DateTimeOffset.Now; - - // ConcurrentBag enumeration is thread-safe without explicit locking - var stepTimings = testContext.Timings.Select(x => - new StepTimingInfo(x.StepName, string.Empty, new TimingInfo(x.Start, x.End, x.Duration))); - - return new TimingProperty(new TimingInfo(overallStart, end, end - overallStart), stepTimings.ToArray()); - } -} diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs new file mode 100644 index 0000000000..ce395525de --- /dev/null +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -0,0 +1,202 @@ +#if NET +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace TUnit.Engine.Reporters.Html; + +internal sealed class ActivityCollector : IDisposable +{ + // Soft caps — intentionally racy for performance; may be slightly exceeded under high concurrency. + private const int MaxSpansPerTrace = 1000; + private const int MaxTotalSpans = 50_000; + + private readonly ConcurrentDictionary> _spansByTrace = new(); + private readonly ConcurrentDictionary _spanCountsByTrace = new(); + private ActivityListener? _listener; + private int _totalSpanCount; + + public void Start() + { + _listener = new ActivityListener + { + ShouldListenTo = static source => IsTUnitSource(source), + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = OnActivityStopped + }; + + ActivitySource.AddActivityListener(_listener); + } + + public void Stop() + { + _listener?.Dispose(); + _listener = null; + } + + public SpanData[] GetAllSpans() + { + return _spansByTrace.Values.SelectMany(q => q).ToArray(); + } + + /// + /// Builds a lookup from TestNode UID to (TraceId, SpanId) by finding the root + /// "test case" span for each test via the tunit.test.node_uid activity tag. + /// + public Dictionary GetTestSpanLookup() + { + var lookup = new Dictionary(); + + foreach (var kvp in _spansByTrace) + { + foreach (var span in kvp.Value) + { + if (!span.Name.StartsWith("test case", StringComparison.Ordinal) || span.Tags is null) + { + continue; + } + + foreach (var tag in span.Tags) + { + if (tag.Key == "tunit.test.node_uid" && !string.IsNullOrEmpty(tag.Value)) + { + lookup[tag.Value] = (span.TraceId, span.SpanId); + break; + } + } + } + } + + return lookup; + } + + private static bool IsTUnitSource(ActivitySource source) => + source.Name.StartsWith("TUnit", StringComparison.Ordinal) || + source.Name.StartsWith("Microsoft.Testing", StringComparison.Ordinal); + + private static string EnrichSpanName(Activity activity) + { + var displayName = activity.DisplayName; + + // Look up the semantic name tag to produce a more descriptive label + var tagKey = displayName switch + { + "test case" => "test.case.name", + "test suite" => "test.suite.name", + "test assembly" => "tunit.assembly.name", + _ => null + }; + + if (tagKey is not null) + { + var value = activity.GetTagItem(tagKey)?.ToString(); + if (!string.IsNullOrEmpty(value)) + { + return $"{displayName}: {value}"; + } + } + + return displayName; + } + + private void OnActivityStopped(Activity activity) + { + var newTotal = Interlocked.Increment(ref _totalSpanCount); + if (newTotal > MaxTotalSpans) + { + Interlocked.Decrement(ref _totalSpanCount); + return; + } + + var traceId = activity.TraceId.ToString(); + var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1); + if (traceCount > MaxSpansPerTrace) + { + Interlocked.Decrement(ref _totalSpanCount); + _spanCountsByTrace.AddOrUpdate(traceId, 0, (_, c) => Math.Max(0, c - 1)); + return; + } + + var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue()); + + ReportKeyValue[]? tags = null; + var tagCollection = activity.TagObjects.ToArray(); + if (tagCollection.Length > 0) + { + tags = new ReportKeyValue[tagCollection.Length]; + for (var i = 0; i < tagCollection.Length; i++) + { + tags[i] = new ReportKeyValue + { + Key = tagCollection[i].Key, + Value = tagCollection[i].Value?.ToString() ?? "" + }; + } + } + + SpanEvent[]? events = null; + var eventCollection = activity.Events.ToArray(); + if (eventCollection.Length > 0) + { + events = new SpanEvent[eventCollection.Length]; + for (var i = 0; i < eventCollection.Length; i++) + { + var evt = eventCollection[i]; + ReportKeyValue[]? evtTags = null; + var evtTagCollection = evt.Tags.ToArray(); + if (evtTagCollection.Length > 0) + { + evtTags = new ReportKeyValue[evtTagCollection.Length]; + for (var j = 0; j < evtTagCollection.Length; j++) + { + evtTags[j] = new ReportKeyValue + { + Key = evtTagCollection[j].Key, + Value = evtTagCollection[j].Value?.ToString() ?? "" + }; + } + } + + events[i] = new SpanEvent + { + Name = evt.Name, + TimestampMs = evt.Timestamp.ToUnixTimeMilliseconds(), + Tags = evtTags + }; + } + } + + var parentSpanId = activity.ParentSpanId != default ? activity.ParentSpanId.ToString() : null; + + var statusStr = activity.Status switch + { + ActivityStatusCode.Ok => "Ok", + ActivityStatusCode.Error => "Error", + _ => "Unset" + }; + + var spanData = new SpanData + { + TraceId = traceId, + SpanId = activity.SpanId.ToString(), + ParentSpanId = parentSpanId, + Name = EnrichSpanName(activity), + Source = activity.Source.Name, + Kind = activity.Kind.ToString(), + StartTimeMs = activity.StartTimeUtc.Subtract(DateTime.UnixEpoch).TotalMilliseconds, + DurationMs = activity.Duration.TotalMilliseconds, + Status = statusStr, + StatusMessage = activity.StatusDescription, + Tags = tags, + Events = events + }; + + queue.Enqueue(spanData); + } + + public void Dispose() + { + Stop(); + } +} +#endif diff --git a/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs b/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs new file mode 100644 index 0000000000..6b60c2e37f --- /dev/null +++ b/TUnit.Engine/Reporters/Html/GitHubArtifactUploader.cs @@ -0,0 +1,280 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace TUnit.Engine.Reporters.Html; + +internal static class GitHubArtifactUploader +{ + private const int MaxRetries = 5; + private const int BaseRetryMs = 3000; + private const double RetryMultiplier = 1.5; + + private static readonly HashSet RetryableStatusCodes = [429, 500, 502, 503, 504]; + private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + + internal static async Task UploadAsync( + string filePath, + string runtimeToken, + string resultsUrl, + CancellationToken cancellationToken) + { + var (workflowRunBackendId, workflowJobRunBackendId) = ExtractBackendIds(runtimeToken); + if (workflowRunBackendId is null || workflowJobRunBackendId is null) + { + Console.WriteLine("Warning: Could not extract backend IDs from ACTIONS_RUNTIME_TOKEN"); + return null; + } + + var origin = new Uri(resultsUrl).GetLeftPart(UriPartial.Authority); + var fileName = Path.GetFileName(filePath); + + // Step 1: CreateArtifact (deduplicate name on 409 conflict) + var createUrl = $"{origin}/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact"; + string? signedUploadUrl = null; + + for (var nameAttempt = 0; nameAttempt < 3 && signedUploadUrl is null; nameAttempt++) + { + var artifactName = nameAttempt == 0 + ? fileName + : $"{Path.GetFileNameWithoutExtension(fileName)}-{nameAttempt + 1}{Path.GetExtension(fileName)}"; + + var createBody = BuildCreateArtifactJson(workflowRunBackendId, workflowJobRunBackendId, artifactName); + + signedUploadUrl = await RetryAsync(async () => + { + using var request = new HttpRequestMessage(HttpMethod.Post, createUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", runtimeToken); + request.Content = new StringContent(createBody, Encoding.UTF8, "application/json"); + + var response = await SharedHttpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + // Artifact name already taken — outer loop will retry with a new name + return null; + } + + await EnsureSuccessAsync(response, "CreateArtifact", cancellationToken); + var json = await response.Content.ReadAsStringAsync( +#if NET + cancellationToken +#endif + ); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("signed_upload_url").GetString(); + }, cancellationToken); + } + + if (signedUploadUrl is null) + { + Console.WriteLine("Warning: CreateArtifact failed — could not obtain signed upload URL"); + return null; + } + + // Step 2: Upload blob + compute SHA256 +#if NET + var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken); + var sha256Hash = Convert.ToHexStringLower(SHA256.HashData(fileBytes)); +#else + var fileBytes = File.ReadAllBytes(filePath); + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(fileBytes); + var sha256Hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); +#endif + + var uploadSucceeded = await RetryAsync(async () => + { + using var request = new HttpRequestMessage(HttpMethod.Put, signedUploadUrl); + request.Content = new ByteArrayContent(fileBytes); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/html"); + request.Headers.Add("x-ms-blob-type", "BlockBlob"); + + var response = await SharedHttpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, "BlobUpload", cancellationToken); + return true; + }, cancellationToken); + + if (uploadSucceeded is not true) + { + Console.WriteLine("Warning: Blob upload failed — skipping artifact finalization"); + return null; + } + + // Step 3: FinalizeArtifact + var finalizeUrl = $"{origin}/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact"; + var finalizeBody = BuildFinalizeArtifactJson(workflowRunBackendId, workflowJobRunBackendId, fileName, fileBytes.Length, sha256Hash); + + var artifactId = await RetryAsync(async () => + { + using var request = new HttpRequestMessage(HttpMethod.Post, finalizeUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", runtimeToken); + request.Content = new StringContent(finalizeBody, Encoding.UTF8, "application/json"); + + var response = await SharedHttpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, "FinalizeArtifact", cancellationToken); + var json = await response.Content.ReadAsStringAsync( +#if NET + cancellationToken +#endif + ); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("artifact_id").GetString(); + }, cancellationToken); + + return artifactId; + } + + private static string BuildCreateArtifactJson(string runId, string jobId, string fileName) + { + using var ms = new MemoryStream(); + using var w = new Utf8JsonWriter(ms); + w.WriteStartObject(); + w.WriteString("workflow_run_backend_id", runId); + w.WriteString("workflow_job_run_backend_id", jobId); + w.WriteString("name", fileName); + w.WriteNumber("version", 7); + w.WriteString("mime_type", "text/html"); + w.WriteEndObject(); + w.Flush(); + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static string BuildFinalizeArtifactJson(string runId, string jobId, string fileName, long size, string sha256Hash) + { + using var ms = new MemoryStream(); + using var w = new Utf8JsonWriter(ms); + w.WriteStartObject(); + w.WriteString("workflow_run_backend_id", runId); + w.WriteString("workflow_job_run_backend_id", jobId); + w.WriteString("name", fileName); + w.WriteString("size", size.ToString()); + w.WriteString("hash", $"sha256:{sha256Hash}"); + w.WriteEndObject(); + w.Flush(); + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static (string? RunId, string? JobId) ExtractBackendIds(string jwt) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return (null, null); + } + + // Base64url decode the payload + var payload = parts[1]; + payload = payload.Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: + payload += "=="; + break; + case 3: + payload += "="; + break; + } + + var bytes = Convert.FromBase64String(payload); + using var doc = JsonDocument.Parse(bytes); + var scp = doc.RootElement.GetProperty("scp").GetString(); + + if (scp is null) + { + return (null, null); + } + + // scp may be space-separated list of scopes; find "Actions.Results:{runId}:{jobId}" + foreach (var scope in scp.Split(' ')) + { + if (!scope.StartsWith("Actions.Results:", StringComparison.Ordinal)) + { + continue; + } + + var colonParts = scope.Split(':'); + if (colonParts.Length >= 3) + { + return (colonParts[1], colonParts[2]); + } + } + + return (null, null); + } + catch (Exception) + { + return (null, null); + } + } + + private static async Task RetryAsync(Func> action, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < MaxRetries; attempt++) + { + try + { + return await action(); + } + catch (HttpRequestException ex) when (IsRetryable(ex)) + { + if (attempt == MaxRetries - 1) + { + Console.WriteLine($"Warning: GitHub artifact upload step failed after {MaxRetries} attempts: {ex.Message}"); + return default; + } + + var delay = (int)(BaseRetryMs * Math.Pow(RetryMultiplier, attempt)); + var jitter = Random.Shared.Next(0, 500); + await Task.Delay(delay + jitter, cancellationToken); + } + } + + return default; + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string step, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var body = await response.Content.ReadAsStringAsync( +#if NET + cancellationToken +#endif + ); + + // Include response body in the exception message rather than logging per-attempt, + // so only the final failure is surfaced by RetryAsync. +#if NET + throw new HttpRequestException($"{step} returned {(int)response.StatusCode}: {body}", null, response.StatusCode); +#else + throw new HttpRequestException($"{step} returned {(int)response.StatusCode}: {body}"); +#endif + } + + private static bool IsRetryable(HttpRequestException ex) + { +#if NET + var statusCode = (int?)ex.StatusCode; + return statusCode is not null && RetryableStatusCodes.Contains(statusCode.Value); +#else + // On netstandard2.0, HttpRequestException doesn't have StatusCode. + // Use message heuristic to avoid retrying non-retryable errors like 401/403. + var msg = ex.Message; + if (msg.Contains("401") || msg.Contains("403") || + msg.Contains("Unauthorized") || msg.Contains("Forbidden")) + { + return false; + } + + return true; +#endif + } +} diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs new file mode 100644 index 0000000000..22d48982a1 --- /dev/null +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -0,0 +1,222 @@ +using System.Text.Json.Serialization; + +namespace TUnit.Engine.Reporters.Html; + +internal sealed class ReportData +{ + [JsonPropertyName("assemblyName")] + public required string AssemblyName { get; init; } + + [JsonPropertyName("machineName")] + public required string MachineName { get; init; } + + [JsonPropertyName("timestamp")] + public required string Timestamp { get; init; } + + [JsonPropertyName("tunitVersion")] + public required string TUnitVersion { get; init; } + + [JsonPropertyName("operatingSystem")] + public required string OperatingSystem { get; init; } + + [JsonPropertyName("runtimeVersion")] + public required string RuntimeVersion { get; init; } + + [JsonPropertyName("filter")] + public string? Filter { get; init; } + + [JsonPropertyName("totalDurationMs")] + public double TotalDurationMs { get; init; } + + [JsonPropertyName("summary")] + public required ReportSummary Summary { get; init; } + + [JsonPropertyName("groups")] + public required ReportTestGroup[] Groups { get; init; } + + [JsonPropertyName("spans")] + public SpanData[]? Spans { get; init; } + + [JsonPropertyName("commitSha")] + public string? CommitSha { get; init; } + + [JsonPropertyName("branch")] + public string? Branch { get; init; } + + [JsonPropertyName("pullRequestNumber")] + public string? PullRequestNumber { get; init; } + + [JsonPropertyName("repositorySlug")] + public string? RepositorySlug { get; init; } +} + +internal sealed class ReportSummary +{ + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("passed")] + public int Passed { get; set; } + + [JsonPropertyName("failed")] + public int Failed { get; set; } + + [JsonPropertyName("skipped")] + public int Skipped { get; set; } + + [JsonPropertyName("cancelled")] + public int Cancelled { get; set; } + + [JsonPropertyName("timedOut")] + public int TimedOut { get; set; } +} + +internal sealed class ReportTestGroup +{ + [JsonPropertyName("className")] + public required string ClassName { get; init; } + + [JsonPropertyName("namespace")] + public required string Namespace { get; init; } + + [JsonPropertyName("summary")] + public required ReportSummary Summary { get; init; } + + [JsonPropertyName("tests")] + public required ReportTestResult[] Tests { get; init; } +} + +internal sealed class ReportTestResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("displayName")] + public required string DisplayName { get; init; } + + [JsonPropertyName("methodName")] + public required string MethodName { get; init; } + + [JsonPropertyName("className")] + public required string ClassName { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("durationMs")] + public double DurationMs { get; init; } + + [JsonPropertyName("startTime")] + public string? StartTime { get; init; } + + [JsonPropertyName("endTime")] + public string? EndTime { get; init; } + + [JsonPropertyName("exception")] + public ReportExceptionData? Exception { get; init; } + + [JsonPropertyName("output")] + public string? Output { get; init; } + + [JsonPropertyName("errorOutput")] + public string? ErrorOutput { get; init; } + + [JsonPropertyName("categories")] + public string[]? Categories { get; init; } + + [JsonPropertyName("customProperties")] + public ReportKeyValue[]? CustomProperties { get; init; } + + [JsonPropertyName("filePath")] + public string? FilePath { get; init; } + + [JsonPropertyName("lineNumber")] + public int? LineNumber { get; init; } + + [JsonPropertyName("skipReason")] + public string? SkipReason { get; init; } + + [JsonPropertyName("retryAttempt")] + public int RetryAttempt { get; init; } + + [JsonPropertyName("traceId")] + public string? TraceId { get; init; } + + [JsonPropertyName("spanId")] + public string? SpanId { get; init; } +} + +internal sealed class ReportExceptionData +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("stackTrace")] + public string? StackTrace { get; init; } + + [JsonPropertyName("innerException")] + public ReportExceptionData? InnerException { get; init; } +} + +internal sealed class ReportKeyValue +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("value")] + public required string Value { get; init; } +} + +internal sealed class SpanData +{ + [JsonPropertyName("traceId")] + public required string TraceId { get; init; } + + [JsonPropertyName("spanId")] + public required string SpanId { get; init; } + + [JsonPropertyName("parentSpanId")] + public string? ParentSpanId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("source")] + public required string Source { get; init; } + + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + [JsonPropertyName("startTimeMs")] + public double StartTimeMs { get; init; } + + [JsonPropertyName("durationMs")] + public double DurationMs { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("statusMessage")] + public string? StatusMessage { get; init; } + + [JsonPropertyName("tags")] + public ReportKeyValue[]? Tags { get; init; } + + [JsonPropertyName("events")] + public SpanEvent[]? Events { get; init; } +} + +internal sealed class SpanEvent +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("timestampMs")] + public double TimestampMs { get; init; } + + [JsonPropertyName("tags")] + public ReportKeyValue[]? Tags { get; init; } +} diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs new file mode 100644 index 0000000000..4c39c5708f --- /dev/null +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -0,0 +1,1863 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace TUnit.Engine.Reporters.Html; + +internal static class HtmlReportGenerator +{ + internal static string GenerateHtml(ReportData data) + { + var sb = new StringBuilder(96 * 1024); + sb.AppendLine(""); + sb.AppendLine(""); + + AppendHead(sb, data); + AppendBody(sb, data); + + sb.AppendLine(""); + return sb.ToString(); + } + + private static void AppendHead(StringBuilder sb, ReportData data) + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.Append("Test Report \u2014 "); + sb.Append(WebUtility.HtmlEncode(data.AssemblyName)); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + } + + private static void AppendBody(StringBuilder sb, ReportData data) + { + sb.AppendLine(""); + + // Skip-to-content link for keyboard/screen-reader users + sb.AppendLine("Skip to test results"); + + // Ambient background grain + sb.AppendLine("
"); + + // Feature 8: Sticky mini-header + sb.Append("
"); + sb.Append(""); + sb.Append(WebUtility.HtmlEncode(data.AssemblyName)); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(data.Summary.Passed); + sb.Append(""); + sb.Append(""); + sb.Append(data.Summary.Failed + data.Summary.TimedOut); + sb.Append(""); + sb.Append(""); + sb.Append(data.Summary.Skipped); + sb.Append(""); + sb.Append(""); + sb.AppendLine(""); + sb.AppendLine("
"); + + sb.AppendLine("
"); + + AppendHeader(sb, data); + + sb.AppendLine("
"); + AppendSummaryDashboard(sb, data.Summary, data.TotalDurationMs); + AppendSearchAndFilters(sb, data.Summary); + + // Quick-access sections populated by JS + sb.AppendLine("
"); + sb.AppendLine("
"); + + AppendTestGroups(sb, data); + sb.AppendLine("
"); + + AppendJsonData(sb, data); + AppendJavaScript(sb); + + sb.AppendLine("
"); + sb.AppendLine(""); + } + + private static void AppendHeader(StringBuilder sb, ReportData data) + { + sb.AppendLine("
"); + sb.AppendLine("
"); + // TUnit logo mark + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.Append("

"); + sb.Append(WebUtility.HtmlEncode(data.AssemblyName)); + sb.AppendLine("

"); + sb.AppendLine("Test Report"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + sb.AppendLine("
"); + AppendMetaChip(sb, "clock", data.Timestamp); + AppendMetaChip(sb, "cpu", data.MachineName); + AppendMetaChip(sb, "os", data.OperatingSystem); + AppendMetaChip(sb, "runtime", data.RuntimeVersion); + AppendMetaChip(sb, "tag", "TUnit " + data.TUnitVersion); + if (!string.IsNullOrEmpty(data.Filter)) + { + AppendMetaChip(sb, "filter", data.Filter!); + } + + if (!string.IsNullOrEmpty(data.Branch)) + { + AppendMetaChip(sb, "branch", data.Branch!); + } + + if (!string.IsNullOrEmpty(data.CommitSha)) + { + var shortSha = data.CommitSha!.Length > 7 ? data.CommitSha[..7] : data.CommitSha; + if (!string.IsNullOrEmpty(data.RepositorySlug)) + { + AppendMetaChipLink(sb, "commit", shortSha, $"https://github.com/{data.RepositorySlug}/commit/{data.CommitSha}"); + } + else + { + AppendMetaChip(sb, "commit", shortSha); + } + } + + if (!string.IsNullOrEmpty(data.PullRequestNumber)) + { + if (!string.IsNullOrEmpty(data.RepositorySlug)) + { + AppendMetaChipLink(sb, "pr", $"PR #{data.PullRequestNumber}", $"https://github.com/{data.RepositorySlug}/pull/{data.PullRequestNumber}"); + } + else + { + AppendMetaChip(sb, "pr", $"PR #{data.PullRequestNumber}"); + } + } + + sb.AppendLine("
"); + + // Theme toggle button + sb.AppendLine(""); + + sb.AppendLine("
"); + } + + private static void AppendMetaChip(StringBuilder sb, string icon, string text) + { + sb.Append(""); + sb.Append(WebUtility.HtmlEncode(text)); + sb.AppendLine(""); + } + + private static void AppendMetaChipLink(StringBuilder sb, string icon, string text, string href) + { + sb.Append(""); + sb.Append(WebUtility.HtmlEncode(text)); + sb.AppendLine(""); + } + + private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summary, double totalDurationMs) + { + var passRate = summary.Total > 0 ? (double)summary.Passed / summary.Total * 100 : 0; + + sb.AppendLine("
"); + + // Ring chart — SVG + var circumference = 2 * Math.PI * 54; // r=54 + var passLen = summary.Total > 0 ? circumference * summary.Passed / summary.Total : 0; + var failLen = summary.Total > 0 ? circumference * (summary.Failed + summary.TimedOut) / summary.Total : 0; + var skipLen = summary.Total > 0 ? circumference * summary.Skipped / summary.Total : 0; + var cancelLen = summary.Total > 0 ? circumference * summary.Cancelled / summary.Total : 0; + + sb.AppendLine("
"); + sb.AppendLine(""); + // Track + sb.AppendLine(""); + + // Segments — stacked with dasharray/dashoffset + double offset = 0; + if (passLen > 0) + { + AppendRingSegment(sb, "var(--emerald)", passLen, offset, circumference); + offset += passLen; + } + + if (failLen > 0) + { + AppendRingSegment(sb, "var(--rose)", failLen, offset, circumference); + offset += failLen; + } + + if (skipLen > 0) + { + AppendRingSegment(sb, "var(--amber)", skipLen, offset, circumference); + offset += skipLen; + } + + if (cancelLen > 0) + { + AppendRingSegment(sb, "var(--slate)", cancelLen, offset, circumference); + } + + sb.AppendLine(""); + sb.Append("
"); + sb.Append(passRate.ToString("F0")); + sb.Append("%"); + sb.Append(summary.Total > 0 ? "pass rate" : "no tests"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + // Stat cards + sb.AppendLine("
"); + AppendStatCard(sb, "total", summary.Total.ToString(), "Total", null); + AppendStatCard(sb, "passed", summary.Passed.ToString(), "Passed", "var(--emerald)"); + AppendStatCard(sb, "failed", (summary.Failed + summary.TimedOut).ToString(), "Failed", "var(--rose)"); + AppendStatCard(sb, "skipped", summary.Skipped.ToString(), "Skipped", "var(--amber)"); + AppendStatCard(sb, "cancelled", summary.Cancelled.ToString(), "Cancelled", "var(--slate)"); + sb.AppendLine("
"); + + // Duration + sb.AppendLine("
"); + sb.Append(""); + sb.Append(FormatDuration(totalDurationMs)); + sb.AppendLine(""); + sb.AppendLine("duration"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + sb.AppendLine("
"); + } + + private static void AppendRingSegment(StringBuilder sb, string color, double len, double offset, double circumference) + { + sb.Append(""); + } + + private static void AppendStatCard(StringBuilder sb, string cls, string count, string label, string? accent) + { + sb.Append("
"); + sb.Append(""); + sb.Append(count); + sb.Append(""); + sb.Append(label); + sb.AppendLine("
"); + } + + private static void AppendSearchAndFilters(StringBuilder sb, ReportSummary summary) + { + sb.AppendLine("
"); + sb.AppendLine("
"); + // Search icon inline SVG + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.AppendLine("
"); + + // Feature 2: Expand/Collapse All + Feature 3: Sort Toggle + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("Group:"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine("Sort:"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine("
"); + + sb.AppendLine(""); + sb.AppendLine("
"); + } + + private static void AppendTestGroups(StringBuilder sb, ReportData data) + { + if (data.Summary.Total == 0) + { + sb.AppendLine("
No tests were discovered.
"); + return; + } + + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void AppendJsonData(StringBuilder sb, ReportData data) + { + sb.Append(""); + } + + private static void AppendJavaScript(StringBuilder sb) + { + sb.AppendLine(""); + } + + private static string FormatDuration(double ms) + { + if (ms < 1) + { + return "<1ms"; + } + + // Show milliseconds for anything under 1 second (avoids rounding 999ms to "1.00s") + if (Math.Round(ms) < 1000) + { + return $"{ms:F0}ms"; + } + + if (ms < 60000) + { + return $"{ms / 1000:F2}s"; + } + + return $"{ms / 60000:F1}m"; + } + + private static string GetCss() + { + return """ +/* ═══════════════════════════════════════════════════════ + TUnit — Dark Observatory Report Theme + ═══════════════════════════════════════════════════════ */ + +/* ── Design Tokens ─────────────────────────────────── */ +:root { + --bg: #0b0d11; + --surface-0: #12151c; + --surface-1: #181c25; + --surface-2: #1f2430; + --surface-3: #282e3a; + --border: rgba(255,255,255,.06); + --border-h: rgba(255,255,255,.10); + + --text: #e2e4e9; + --text-2: #9ba1b0; + --text-3: #5f6678; + + --emerald: #34d399; + --emerald-d: rgba(52,211,153,.12); + --rose: #fb7185; + --rose-d: rgba(251,113,133,.12); + --amber: #fbbf24; + --amber-d: rgba(251,191,36,.10); + --slate: #94a3b8; + --slate-d: rgba(148,163,184,.10); + --indigo: #818cf8; + --indigo-d: rgba(129,140,248,.10); + + --font: 'Segoe UI Variable','Segoe UI',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; + --mono: 'Cascadia Code','JetBrains Mono','Fira Code','SF Mono',ui-monospace,monospace; + + --r: 8px; + --r-lg: 14px; + --ease: cubic-bezier(.4,0,.2,1); +} + +/* ── Light Theme ──────────────────────────────────── */ +:root[data-theme="light"]{ + --bg:#f8f9fb;--surface-0:#ffffff;--surface-1:#f0f1f4;--surface-2:#e4e6eb;--surface-3:#d1d5de; + --border:rgba(0,0,0,.08);--border-h:rgba(0,0,0,.14); + --text:#1a1d24;--text-2:#5a5f6e;--text-3:#8b91a0; + --emerald-d:rgba(52,211,153,.15);--rose-d:rgba(251,113,133,.15); + --amber-d:rgba(251,191,36,.12);--slate-d:rgba(148,163,184,.12); + --indigo-d:rgba(129,140,248,.12); +} +:root[data-theme="light"] .grain{opacity:.008} + +/* ── Theme Transition ─────────────────────────────── */ +.theme-transition,.theme-transition *,.theme-transition *::before,.theme-transition *::after{ + transition:background-color .3s var(--ease),color .3s var(--ease),border-color .3s var(--ease),box-shadow .3s var(--ease)!important; +} + +/* ── Reset & Base ──────────────────────────────────── */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} +body{ + font-family:var(--font);background:var(--bg);color:var(--text); + line-height:1.55;font-size:14px;min-height:100vh; + overflow-x:hidden; +} + +/* Film-grain overlay for atmosphere */ +.grain{ + position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.018; + background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ── Shell ─────────────────────────────────────────── */ +.shell{max-width:1360px;margin:0 auto;padding:28px 24px 64px} + +/* ── Animations ────────────────────────────────────── */ +@keyframes fade-up{ + from{opacity:0;transform:translateY(14px)} + to{opacity:1;transform:none} +} +@keyframes ring-draw{ + from{stroke-dashoffset:340} +} +[data-anim]{animation:fade-up .5s var(--ease) both} +[data-anim]:nth-child(2){animation-delay:.08s} +[data-anim]:nth-child(3){animation-delay:.16s} +.ring-seg{animation:ring-draw .9s var(--ease) both} + +/* Stat card stagger */ +[data-anim] .stat{animation:fade-up .4s var(--ease) both} +[data-anim] .stat:nth-child(1){animation-delay:0s} +[data-anim] .stat:nth-child(2){animation-delay:.05s} +[data-anim] .stat:nth-child(3){animation-delay:.1s} +[data-anim] .stat:nth-child(4){animation-delay:.15s} +[data-anim] .stat:nth-child(5){animation-delay:.2s} + +/* Test row stagger on group open */ +.grp.open .t-row{animation:fade-up .3s var(--ease) both;animation-delay:calc(var(--row-idx,0) * .03s)} + +/* Reduced motion */ +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} + +/* ── Header ────────────────────────────────────────── */ +.hdr{ + display:flex;align-items:center;flex-wrap:wrap; + gap:10px 14px;margin-bottom:16px; + animation:fade-up .5s var(--ease) both; +} +.hdr-brand{display:flex;align-items:center;gap:12px} +.hdr-logo{width:38px;height:38px;flex-shrink:0} +.hdr-name{ + font-size:1.35rem;font-weight:700;letter-spacing:-.02em; + background:linear-gradient(135deg,#e2e4e9 30%,#818cf8); + -webkit-background-clip:text;-webkit-text-fill-color:transparent; + background-clip:text; +} +:root[data-theme="light"] .hdr-name{background:linear-gradient(135deg,#1a1d24 30%,#6366f1);-webkit-background-clip:text;background-clip:text} +.hdr-sub{font-size:.78rem;color:var(--text-3);letter-spacing:.06em;text-transform:uppercase} +.hdr-meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-left:auto} +.chip{ + display:inline-flex;align-items:center;gap:6px; + padding:5px 12px;border-radius:100px; + background:var(--surface-1);border:1px solid var(--border); + font-size:.78rem;color:var(--text-2);white-space:nowrap; +} +.chip-link{ + text-decoration:none;cursor:pointer; + transition:border-color .2s var(--ease),background .2s var(--ease); +} +.chip-link:hover{border-color:var(--indigo);background:var(--indigo-d);color:var(--text)} + +/* ── Theme Toggle ─────────────────────────────────── */ +.theme-btn{ + display:flex;align-items:center;justify-content:center; + width:36px;height:36px;border-radius:100px; + background:var(--surface-1);border:1px solid var(--border); + color:var(--text-2);cursor:pointer;flex-shrink:0; + transition:border-color .2s var(--ease),color .2s var(--ease),background .2s var(--ease); +} +.theme-btn:hover{border-color:var(--border-h);color:var(--text)} +.theme-btn svg{width:18px;height:18px;transition:transform .3s var(--ease)} +.theme-btn:active svg{transform:rotate(30deg)} +[data-theme="dark"] .theme-sun{display:none} +[data-theme="light"] .theme-moon{display:none} + +/* ── Dashboard ─────────────────────────────────────── */ +.dash{ + display:flex;align-items:center;gap:32px;flex-wrap:wrap; + padding:28px;margin-bottom:24px; + background:var(--surface-0); + border:1px solid var(--border);border-radius:var(--r-lg); + position:relative;overflow:hidden; +} +.dash::before{ + content:'';position:absolute;inset:0; + background:radial-gradient(ellipse 60% 50% at 20% 50%,rgba(99,102,241,.04),transparent), + radial-gradient(ellipse 40% 60% at 80% 30%,rgba(52,211,153,.03),transparent); + pointer-events:none; +} + +/* Ring */ +.ring-wrap{position:relative;width:130px;height:130px;flex-shrink:0} +.ring{width:100%;height:100%} +.ring-center{ + position:absolute;inset:0;display:flex;flex-direction:column; + align-items:center;justify-content:center; +} +.ring-pct{font-size:1.7rem;font-weight:800;letter-spacing:-.03em;line-height:1} +.ring-pct small{font-size:.55em;font-weight:600;opacity:.6} +.ring-lbl{font-size:.68rem;color:var(--text-3);margin-top:2px;letter-spacing:.04em;text-transform:uppercase} + +/* Stat cards */ +.stats{display:flex;gap:10px;flex-wrap:wrap;flex:1} +.stat{ + position:relative;flex:1;min-width:88px; + padding:16px 14px;border-radius:var(--r); + background:var(--surface-1);border:1px solid var(--border); + text-align:center;transition:border-color .2s var(--ease),transform .2s var(--ease); + overflow:hidden; +} +.stat::after{ + content:'';position:absolute;top:0;left:0;right:0;height:2px; + background:var(--accent,var(--indigo));border-radius:2px 2px 0 0; + opacity:.7; +} +.stat:hover{border-color:var(--border-h);transform:translateY(-1px)} +.stat-n{display:block;font-size:1.65rem;font-weight:800;letter-spacing:-.03em;line-height:1.1;font-variant-numeric:tabular-nums} +.stat-l{display:block;font-size:.72rem;color:var(--text-3);margin-top:4px;text-transform:uppercase;letter-spacing:.06em} + +/* coloured numbers */ +.stat.passed .stat-n{color:var(--emerald)} +.stat.failed .stat-n{color:var(--rose)} +.stat.skipped .stat-n{color:var(--amber)} + +/* Duration */ +.dash-dur{text-align:center;padding:4px 20px;flex-shrink:0} +.dash-dur-val{display:block;font-size:1.5rem;font-weight:800;font-family:var(--mono);letter-spacing:-.02em} +.dash-dur-lbl{display:block;font-size:.68rem;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-top:2px} + +/* ── Toolbar (search + pills) ──────────────────────── */ +.bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;justify-content:flex-end} +.search{ + position:relative;flex:1;min-width:220px; +} +.search-icon{ + position:absolute;left:11px;top:50%;transform:translateY(-50%); + width:16px;height:16px;color:var(--text-3);pointer-events:none; +} +.search input{ + width:100%;padding:9px 34px 9px 34px; + background:var(--surface-1);border:1px solid var(--border);border-radius:var(--r); + color:var(--text);font-size:.88rem;font-family:var(--font); + transition:border-color .2s var(--ease),box-shadow .2s var(--ease); + outline:none; +} +.search input::placeholder{color:var(--text-3)} +.search input:focus{border-color:rgba(129,140,248,.4);box-shadow:0 0 0 3px rgba(129,140,248,.08)} +.search-clear{ + position:absolute;right:8px;top:50%;transform:translateY(-50%); + background:none;border:none;color:var(--text-3);font-size:1.15rem; + cursor:pointer;display:none;line-height:1;padding:2px; +} +.search-clear:hover{color:var(--text)} + +/* Filter pills */ +.pills{display:flex;gap:5px} +.pill{ + display:inline-flex;align-items:center;gap:5px; + padding:7px 14px;border-radius:100px; + background:var(--surface-1);border:1px solid var(--border); + color:var(--text-2);font-size:.8rem;cursor:pointer; + font-family:var(--font); + transition:all .18s var(--ease);white-space:nowrap; +} +.pill:hover{border-color:var(--border-h);color:var(--text)} +.pill.active{background:var(--indigo);border-color:var(--indigo);color:#fff} +.dot{width:7px;height:7px;border-radius:50%;display:inline-block} +.dot.emerald{background:var(--emerald)} +.dot.rose{background:var(--rose)} +.dot.amber{background:var(--amber)} +.dot.slate{background:var(--slate)} +.bar-info{font-size:.8rem;color:var(--text-3);margin-left:auto} + +/* ── Groups ────────────────────────────────────────── */ +.groups{display:flex;flex-direction:column;gap:6px} +.grp{ + background:var(--surface-0);border:1px solid var(--border); + border-radius:var(--r);overflow:hidden; + transition:border-color .2s var(--ease); +} +.grp:hover{border-color:var(--border-h)} +.grp-hd{ + display:flex;align-items:center;gap:12px;padding:9px 16px; + cursor:pointer;user-select:none; + transition:background .15s var(--ease); +} +.grp-hd:hover{background:var(--surface-1)} +.grp-arrow{ + width:16px;height:16px;color:var(--text-3);flex-shrink:0; + transition:transform .2s var(--ease); +} +.grp.open .grp-arrow{transform:rotate(90deg)} +.grp-name{font-weight:600;font-size:.9rem;flex:1;word-break:break-word} +.grp-badges{display:flex;gap:6px;flex-shrink:0} +.grp-b{ + display:inline-flex;align-items:center;gap:3px; + font-size:.72rem;padding:2px 8px;border-radius:100px; + font-weight:600;font-variant-numeric:tabular-nums; +} +.grp-b.gp{background:var(--emerald-d);color:var(--emerald)} +.grp-b.gf{background:var(--rose-d);color:var(--rose)} +.grp-b.gs{background:var(--amber-d);color:var(--amber)} +.grp-b.gt{color:var(--text-3);font-weight:500} +.grp-indicator{ + width:4px;height:18px;border-radius:2px;flex-shrink:0; + background:var(--emerald);opacity:.6; +} +.grp-hd.fail .grp-indicator{background:var(--rose)} +.grp-body{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)} +.grp.open .grp-body{grid-template-rows:1fr} +.grp-body-inner{overflow:hidden;min-height:0} +.grp-body-pad{border-top:1px solid var(--border)} + +/* ── Test Rows ─────────────────────────────────────── */ +.t-row{ + display:flex;align-items:center;gap:10px; + padding:9px 16px 9px 20px; + border-bottom:1px solid var(--border); + cursor:pointer;transition:background .12s var(--ease); +} +.t-row:last-child{border-bottom:none} +.t-row:hover{background:rgba(255,255,255,.02)} +.t-badge{ + font-size:.7rem;font-weight:700;padding:3px 9px;border-radius:100px; + text-transform:uppercase;letter-spacing:.04em;white-space:nowrap; + line-height:1; +} +.t-badge.passed{background:var(--emerald-d);color:var(--emerald);box-shadow:0 0 6px rgba(52,211,153,.15)} +.t-badge.failed,.t-badge.error,.t-badge.timedOut{background:var(--rose-d);color:var(--rose);box-shadow:0 0 6px rgba(251,113,133,.15)} +.t-badge.skipped{background:var(--amber-d);color:var(--amber);box-shadow:0 0 6px rgba(251,191,36,.12)} +.t-badge.cancelled{background:var(--slate-d);color:var(--slate)} +.t-badge.inProgress,.t-badge.unknown{background:var(--surface-2);color:var(--text-3)} +.t-name{flex:1;font-size:.88rem;word-break:break-word;color:var(--text)} +.t-dur{font-size:.78rem;color:var(--text-3);font-family:var(--mono);white-space:nowrap;font-variant-numeric:tabular-nums} +.retry-tag{ + font-size:.65rem;font-weight:700;padding:2px 7px;border-radius:4px; + background:var(--amber-d);color:var(--amber);white-space:nowrap; +} + +/* ── Test Detail Panel ─────────────────────────────── */ +.t-detail{ + display:grid;grid-template-rows:0fr; + transition:grid-template-rows .3s var(--ease); +} +.t-detail.open{grid-template-rows:1fr} +.t-detail-inner{overflow:hidden;min-height:0} +.t-detail-pad{padding:14px 18px 14px 22px;background:var(--surface-1);border-bottom:1px solid var(--border)} +.d-sec{margin-bottom:14px} +.d-sec:last-child{margin-bottom:0} +.d-info{ + display:flex;gap:20px;flex-wrap:wrap; + padding:10px 14px;border-radius:var(--r); + background:var(--surface-0);border:1px solid var(--border); +} +.d-info-item{font-size:.82rem;color:var(--text-2)} +.d-info-label{font-size:.68rem;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.07em;margin-right:4px} +.d-collapsible .d-collapse-toggle{ + display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none; + font-size:.68rem;font-weight:700;text-transform:uppercase; + color:var(--text-3);letter-spacing:.07em;margin-bottom:5px; + transition:color .15s var(--ease); +} +.d-collapsible .d-collapse-toggle:hover{color:var(--text)} +.d-collapsible .d-collapse-toggle .tl-arrow{transition:transform .2s var(--ease);flex-shrink:0} +.d-collapsible.d-col-open .d-collapse-toggle .tl-arrow{transform:rotate(90deg)} +.d-collapsible .d-collapse-content{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)} +.d-collapsible.d-col-open .d-collapse-content{grid-template-rows:1fr} +.d-collapse-inner{overflow:hidden;min-height:0} +.d-lbl{ + font-size:.68rem;font-weight:700;text-transform:uppercase; + color:var(--text-3);margin-bottom:5px;letter-spacing:.07em; +} +.d-pre{ + background:var(--surface-0);border:1px solid var(--border); + border-radius:var(--r);padding:10px 12px; + font-family:var(--mono);font-size:.8rem;color:var(--text-2); + white-space:pre-wrap;word-break:break-word; + max-height:320px;overflow:auto;line-height:1.5; + border-left:2px solid var(--indigo); +} +.d-pre.err{color:var(--rose);border-color:rgba(251,113,133,.15);border-left:2px solid var(--rose)} +.d-pre.stack{color:var(--text-3);font-size:.76rem;border-left-color:var(--text-3)} +.d-tags{display:flex;gap:6px;flex-wrap:wrap} +.d-tag{ + padding:3px 10px;border-radius:100px;font-size:.76rem; + background:var(--surface-2);color:var(--text-2);border:1px solid var(--border); +} +.d-tag.kv{font-family:var(--mono);font-size:.72rem} + +.d-src{font-size:.78rem;color:var(--text-3);font-family:var(--mono)} + +/* ── Copy Button ──────────────────────────────────── */ +.d-pre-wrap{position:relative} +.d-pre-wrap .d-pre{margin:0} +.copy-btn{ + position:absolute;top:6px;right:6px; + background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r); + color:var(--text-3);cursor:pointer;padding:4px 6px; + opacity:0;transition:opacity .15s var(--ease),color .15s var(--ease),border-color .15s var(--ease); + display:flex;align-items:center;justify-content:center; +} +.d-pre-wrap:hover .copy-btn{opacity:1} +.copy-btn:hover{color:var(--text);border-color:var(--border-h)} +.copy-btn.copied{color:var(--emerald);border-color:var(--emerald)} +.copy-btn svg{width:14px;height:14px} + +/* ── Trace Timeline ────────────────────────────────── */ +.trace{margin-top:6px} +.sp-row{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:.78rem;cursor:pointer} +.sp-row:hover .sp-bar{filter:brightness(1.2)} +.sp-indent{flex-shrink:0} +.sp-bar{height:14px;border-radius:3px;min-width:3px;transition:filter .15s} +.sp-bar.ok{background:linear-gradient(90deg,rgba(52,211,153,.6),var(--emerald))} +.sp-bar.err{background:linear-gradient(90deg,rgba(251,113,133,.6),var(--rose))} +.sp-bar.unk{background:linear-gradient(90deg,rgba(148,163,184,.4),var(--slate))} +.sp-name{color:var(--text-2);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sp-dur{font-family:var(--mono);color:var(--text-3);font-size:.72rem} +.sp-extra{ + display:none;padding:6px 10px;margin:2px 0 4px; + background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r); + font-size:.76rem;color:var(--text-2); +} +.sp-extra.open{display:block;animation:fade-up .2s var(--ease)} +.global-trace,.suite-trace{ + background:var(--surface-1);border:1px solid var(--border);border-radius:var(--r-lg); + padding:0;margin-bottom:16px;overflow:hidden; +} +.suite-trace{margin:0 0 12px;background:var(--surface-0);border-radius:var(--r)} +.tl-toggle{ + display:flex;align-items:center;gap:6px;padding:12px 16px;cursor:pointer; + user-select:none;font-size:.82rem;font-weight:600;color:var(--text-2); + transition:color .15s var(--ease); +} +.suite-trace .tl-toggle{padding:10px 14px;font-size:.78rem} +.tl-toggle:hover{color:var(--text)} +.tl-toggle .tl-arrow{transition:transform .2s var(--ease);flex-shrink:0} +.tl-open .tl-arrow{transform:rotate(90deg)} +.tl-content{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)} +.tl-open .tl-content{grid-template-rows:1fr} +.tl-content-inner{overflow:hidden;min-height:0} +.tl-content-pad{padding:0 16px 14px} +.suite-trace .tl-content-pad{padding:0 14px 10px} + +/* ── Group Summary ─────────────────────────────────── */ +.grp-summary{ + display:flex;gap:20px;flex-wrap:wrap;align-items:center; + padding:10px 16px;background:var(--surface-1); + border-bottom:1px solid var(--border); + font-size:.82rem;color:var(--text-2); +} +.grp-summary .d-info-label{font-size:.68rem;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.07em;margin-right:4px} +.grp-summary .grp-sum-dur{font-family:var(--mono);font-weight:600;color:var(--text)} + +/* ── Quick-Access Sections ─────────────────────────── */ +.qa-section{ + background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg); + margin-bottom:16px;overflow:hidden; +} +.qa-section .tl-toggle{padding:12px 16px;font-size:.82rem;font-weight:600;color:var(--text-2)} +.qa-section .tl-content-pad{padding:0 16px 12px} +.qa-item{ + display:flex;align-items:center;gap:10px;padding:8px 12px; + border-radius:var(--r);cursor:pointer; + transition:background .12s var(--ease); +} +.qa-item:hover{background:var(--surface-2)} +.qa-info{flex:1;min-width:0} +.qa-info-name{font-size:.86rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.qa-info-class{font-size:.72rem;color:var(--text-3)} +.qa-err{ + font-size:.74rem;color:var(--rose);font-family:var(--mono); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + max-width:340px; +} +.qa-dur{font-size:.78rem;color:var(--text-3);font-family:var(--mono);white-space:nowrap;flex-shrink:0;font-variant-numeric:tabular-nums} + +/* Slowest tests */ +.slow-rank{ + font-size:.72rem;font-weight:800;color:var(--text-3); + min-width:22px;text-align:center;flex-shrink:0; + font-variant-numeric:tabular-nums; +} +.slow-bar-track{flex:1;height:6px;border-radius:3px;background:var(--surface-2);overflow:hidden;min-width:60px} +.slow-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--amber),var(--rose));min-width:2px} + +/* ── Highlight Flash ───────────────────────────────── */ +@keyframes flash-highlight{ + 0%{background:rgba(129,140,248,.18)} + 100%{background:transparent} +} +.qa-highlight{animation:flash-highlight 1.5s var(--ease)} + +/* ── Empty State ───────────────────────────────────── */ +.empty{ + text-align:center;padding:64px 24px; + color:var(--text-3);font-size:1rem; + background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg); +} + +/* ── Responsive ────────────────────────────────────── */ +@media(max-width:768px){ + .shell{padding:16px 12px 48px} + .dash{flex-direction:column;align-items:stretch;gap:20px;padding:20px} + .ring-wrap{align-self:center} + .stats{justify-content:center} + .bar{flex-direction:column;align-items:stretch} + .search{max-width:none} + .hdr{flex-direction:column} + .qa-err{display:none} +} + +/* ── Print ─────────────────────────────────────────── */ +@media print{ + :root{--bg:#fff;--surface-0:#fff;--surface-1:#f9f9f9;--surface-2:#f0f0f0;--surface-3:#e0e0e0; + --border:rgba(0,0,0,.1);--border-h:rgba(0,0,0,.2);--text:#000;--text-2:#333;--text-3:#666} + body{background:#fff;color:#000} + .grain,.bar,.theme-btn{display:none} + .grp-body{grid-template-rows:1fr!important} + .t-detail{grid-template-rows:1fr!important;background:#f9f9f9} + .shell{max-width:none;padding:0} +} + +/* ── Scrollbar ─────────────────────────────────────── */ +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--surface-3);border-radius:3px} +::-webkit-scrollbar-thumb:hover{background:var(--text-3)} + +/* ── Light Mode Hover Adjustments ─────────────────── */ +:root[data-theme="light"] .t-row:hover{background:rgba(0,0,0,.02)} +:root[data-theme="light"] .dash::before{ + background:radial-gradient(ellipse 60% 50% at 20% 50%,rgba(99,102,241,.06),transparent), + radial-gradient(ellipse 40% 60% at 80% 30%,rgba(52,211,153,.04),transparent); +} + +/* ── Feature 1: Pill Counts ───────────────────────── */ +.pill-count{font-size:.72rem;opacity:.7;font-variant-numeric:tabular-nums;margin-left:2px} +.pill.active .pill-count{opacity:.85} + +/* ── Feature 2: Expand/Collapse All ──────────────── */ +.bar-actions{display:flex;align-items:center;gap:6px;flex-basis:100%;justify-content:flex-end} +.bar-btn{ + display:flex;align-items:center;justify-content:center; + width:32px;height:32px;border-radius:var(--r); + background:var(--surface-1);border:1px solid var(--border); + color:var(--text-3);cursor:pointer; + transition:border-color .2s var(--ease),color .2s var(--ease),background .2s var(--ease); +} +.bar-btn:hover{border-color:var(--border-h);color:var(--text)} + +/* ── Feature 3: Sort Toggle ──────────────────────── */ +.bar-sep{width:1px;height:18px;background:var(--border);margin:0 4px} +.bar-lbl{font-size:.72rem;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap} +.sort-btn{ + padding:5px 10px;border-radius:100px;font-size:.74rem; + background:var(--surface-1);border:1px solid var(--border); + color:var(--text-2);cursor:pointer;font-family:var(--font); + transition:all .18s var(--ease);white-space:nowrap; +} +.sort-btn:hover{border-color:var(--border-h);color:var(--text)} +.sort-btn.active{background:var(--indigo);border-color:var(--indigo);color:#fff} + +/* ── Feature 4: Search Highlighting ──────────────── */ +mark{background:rgba(251,191,36,.25);color:inherit;border-radius:2px;padding:0 1px} +:root[data-theme="light"] mark{background:rgba(251,191,36,.35)} + +/* ── Feature 5: Copy Deep-Link Button ────────────── */ +.t-link-btn{ + display:inline-flex;align-items:center;justify-content:center; + width:24px;height:24px;border-radius:4px; + background:transparent;border:none;color:var(--text-3); + cursor:pointer;opacity:0;transition:opacity .15s var(--ease),color .15s var(--ease); + flex-shrink:0;position:relative; +} +.t-row:hover .t-link-btn{opacity:.6} +.t-link-btn:hover{opacity:1!important;color:var(--indigo)} +.t-link-copied{ + position:absolute;bottom:100%;left:50%;transform:translateX(-50%); + padding:2px 8px;border-radius:4px;font-size:.68rem;white-space:nowrap; + background:var(--surface-3);color:var(--text);pointer-events:none; + animation:fade-up .2s var(--ease); +} + +/* ── Feature 6: Diff-Friendly Error Display ──────── */ +.err-expected{color:var(--emerald);font-weight:600} +.err-actual{color:var(--rose);font-weight:600} + +/* ── Feature 7: Keyboard Navigation ──────────────── */ +.t-row.kb-focus{outline:2px solid var(--indigo);outline-offset:-2px;background:var(--indigo-d)} +.kb-overlay{position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center} +.kb-modal{ + background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg); + padding:24px 28px;max-width:380px;width:90%;box-shadow:0 24px 48px rgba(0,0,0,.3); +} +.kb-modal h3{font-size:1rem;font-weight:700;margin-bottom:14px;color:var(--text)} +.kb-row{display:flex;justify-content:space-between;align-items:center;padding:5px 0;font-size:.84rem;color:var(--text-2)} +.kb-key{ + display:inline-flex;align-items:center;justify-content:center; + min-width:24px;height:24px;padding:0 7px;border-radius:4px; + background:var(--surface-2);border:1px solid var(--border); + font-family:var(--mono);font-size:.74rem;font-weight:600;color:var(--text); +} +:root[data-theme="light"] .kb-overlay{background:rgba(0,0,0,.3)} + +/* ── Feature 8: Sticky Mini-Header ───────────────── */ +.sticky-bar{ + position:sticky;top:0;z-index:100; + display:flex;align-items:center;gap:12px; + padding:8px 24px; + background:rgba(11,13,17,.85);backdrop-filter:blur(12px); + border-bottom:1px solid var(--border); + transform:translateY(-100%);opacity:0; + transition:transform .25s var(--ease),opacity .25s var(--ease); +} +.sticky-bar.visible{transform:none;opacity:1} +.sticky-name{font-size:.84rem;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.sticky-badges{display:flex;gap:6px;flex-shrink:0} +.sticky-b{ + font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:100px; + font-variant-numeric:tabular-nums; +} +.sb-pass{background:var(--emerald-d);color:var(--emerald)} +.sb-fail{background:var(--rose-d);color:var(--rose)} +.sb-skip{background:var(--amber-d);color:var(--amber)} +.sticky-search-btn{ + margin-left:auto;display:flex;align-items:center;justify-content:center; + width:28px;height:28px;border-radius:var(--r); + background:var(--surface-2);border:1px solid var(--border); + color:var(--text-3);cursor:pointer; + transition:color .15s var(--ease),border-color .15s var(--ease); +} +.sticky-search-btn:hover{color:var(--text);border-color:var(--border-h)} +:root[data-theme="light"] .sticky-bar{background:rgba(248,249,251,.85)} + +/* ── Feature 9: 100% Pass Celebration ────────────── */ +@keyframes shimmer{ + 0%{background-position:200% 0} + 100%{background-position:-200% 0} +} +@keyframes ring-glow{ + 0%,100%{filter:drop-shadow(0 0 6px rgba(52,211,153,.3))} + 50%{filter:drop-shadow(0 0 16px rgba(52,211,153,.6))} +} +.dash.celebrate .stat{ + background-image:linear-gradient(90deg,transparent 30%,rgba(52,211,153,.06) 50%,transparent 70%); + background-size:200% 100%;animation:shimmer 3s linear infinite; +} +.dash.celebrate .ring{animation:ring-glow 2.5s ease-in-out infinite} +@media(prefers-reduced-motion:reduce){ + .dash.celebrate .stat,.dash.celebrate .ring{animation:none} +} + +/* ── Feature 10: Duration Histogram ──────────────── */ +.dur-hist{display:flex;align-items:flex-end;gap:2px;height:36px;margin-top:8px} +.dur-hist-bar{ + flex:1;min-width:0;border-radius:2px 2px 0 0; + background:linear-gradient(to top,var(--indigo),rgba(129,140,248,.5)); + position:relative;cursor:default; + transition:filter .15s var(--ease); +} +.dur-hist-bar:hover{filter:brightness(1.3)} +.dur-hist-bar::after{ + content:attr(data-tip); + position:absolute;bottom:100%;left:50%;transform:translateX(-50%); + padding:3px 8px;border-radius:4px;font-size:.66rem;white-space:nowrap; + background:var(--surface-3);color:var(--text);pointer-events:none; + opacity:0;transition:opacity .15s var(--ease); +} +.dur-hist-bar:hover::after{opacity:1} + +/* ── Lazy Sentinel ──────────────────────────────── */ +.lazy-sentinel{display:flex;align-items:center;justify-content:center;padding:16px;color:var(--text-3);font-size:.82rem} + +/* ── Accessibility: Skip Link ────────────────────── */ +.skip-link{ + position:absolute;top:-100%;left:16px; + padding:8px 16px;border-radius:var(--r); + background:var(--indigo);color:#fff;font-size:.84rem;font-weight:600; + z-index:10000;text-decoration:none; + transition:top .2s var(--ease); +} +.skip-link:focus{top:8px} + +/* ── Accessibility: Focus-Visible ────────────────── */ +:focus-visible{outline:2px solid var(--indigo);outline-offset:2px;border-radius:var(--r)} +.pill:focus-visible,.sort-btn:focus-visible{outline-offset:0} +.search input:focus-visible{outline:none} /* uses custom box-shadow instead */ +.t-row:focus-visible{outline-offset:-2px} + +/* ── Accessibility: Touch Targets ────────────────── */ +@media(pointer:coarse){ + .theme-btn{width:44px;height:44px} + .bar-btn{width:40px;height:40px} + .pill{padding:10px 16px} + .sort-btn{padding:8px 14px} + .t-row{padding:12px 16px 12px 20px;min-height:44px} + .t-link-btn{width:36px;height:36px;opacity:.5} + .grp-hd{padding:12px 16px;min-height:44px} + .sticky-search-btn{width:36px;height:36px} +} + +/* ── Accessibility: Contrast Boost ───────────────── */ +/* Dark theme: bump secondary/tertiary text to meet WCAG AA */ +:root{ + --text-2:#a8aebb; + --text-3:#717a8c; +} +:root[data-theme="light"]{ + --text-2:#4a5060; + --text-3:#6b7280; +} + +/* ── Accessibility: Sort Group ───────────────────── */ +.sort-group{display:flex;gap:4px;align-items:center} +.grp-toggle{display:flex;gap:4px;align-items:center} + +/* ── Mobile Improvements ─────────────────────────── */ +@media(max-width:768px){ + .bar-actions{width:100%;justify-content:flex-end} + .bar-sep{display:none} + .sticky-bar{padding:8px 12px;gap:8px} + .sticky-name{font-size:.78rem;max-width:120px} +} +@media(max-width:480px){ + .pills{flex-wrap:wrap} + .pill .pill-count{display:none} + .sort-group{flex-wrap:wrap} + .grp-toggle{flex-wrap:wrap} +} + +/* ── Print Improvements ──────────────────────────── */ +@media print{ + .skip-link,.sticky-bar,.bar-actions,.t-link-btn,.search,.copy-btn,.theme-btn{display:none!important} + .grp{break-inside:avoid} + .t-row{break-inside:avoid} + .t-badge{-webkit-print-color-adjust:exact;print-color-adjust:exact} + .grp-b{-webkit-print-color-adjust:exact;print-color-adjust:exact} + .dot{-webkit-print-color-adjust:exact;print-color-adjust:exact} + .ring-seg{stroke-opacity:1!important} +} + +/* ── High Contrast Mode ──────────────────────────── */ +@media(forced-colors:active){ + .pill.active{border:2px solid LinkText} + .sort-btn.active{border:2px solid LinkText} + .t-badge{border:1px solid CanvasText} + .grp-indicator{forced-color-adjust:none} + .t-row.kb-focus{outline:2px solid LinkText} +} +"""; + } + + private static string GetJavaScript() + { + return """ +(function(){ +'use strict'; +const raw = document.getElementById('test-data'); +if (!raw) return; +const data = JSON.parse(raw.textContent); +const groups = data.groups || []; +const spans = data.spans || []; +const container = document.getElementById('testGroups'); +const searchInput = document.getElementById('searchInput'); +const clearBtn = document.getElementById('clearSearch'); +const filterBtns = document.getElementById('filterButtons'); +const filterSummary = document.getElementById('filterSummary'); +let activeFilter = 'all'; +let searchText = ''; +let debounceTimer; +let sortMode = 'default'; +let groupMode = 'class'; +let renderLimit = 20; +let kbIdx = -1; + +const spansByTrace = {}; +const bySpanId = {}; +spans.forEach(s => { + if (!spansByTrace[s.traceId]) spansByTrace[s.traceId] = []; + spansByTrace[s.traceId].push(s); + bySpanId[s.spanId] = s; +}); + +// Build suite span lookup: className -> span +const suiteSpanByClass = {}; +spans.forEach(s => { + if (!s.name.startsWith('test suite')) return; + const tag = (s.tags||[]).find(t => t.key === 'test.suite.name'); + if (tag) suiteSpanByClass[tag.value] = s; +}); + +function matchesFilter(t) { + if (activeFilter !== 'all') { + if (activeFilter === 'failed') { + if (t.status !== 'failed' && t.status !== 'error' && t.status !== 'timedOut') return false; + } else if (t.status !== activeFilter) return false; + } + if (searchText) { + const q = searchText.toLowerCase(); + const h = (t.displayName + ' ' + t.className + ' ' + (t.categories||[]).join(' ') + ' ' + (t.traceId||'') + ' ' + (t.spanId||'')).toLowerCase(); + if (!h.includes(q)) return false; + } + return true; +} + +function fmt(ms) { + if (ms < 1) return '<1ms'; + if (Math.round(ms) < 1000) return Math.round(ms) + 'ms'; + if (ms < 60000) return (ms/1000).toFixed(2) + 's'; + return (ms/60000).toFixed(1) + 'm'; +} + +function esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +// Feature 4: Search highlight helper +function highlight(text, query) { + if (!query) return esc(text); + const escaped = esc(text); + const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); + return escaped.replace(re, '$1'); +} + +// Feature 3: Sort comparator +const statusOrder = {failed:0,error:0,timedOut:1,inProgress:2,unknown:3,passed:4,skipped:5,cancelled:6}; +function sortTests(tests) { + if (sortMode === 'duration') return [...tests].sort((a,b) => b.durationMs - a.durationMs); + if (sortMode === 'name') return [...tests].sort((a,b) => a.displayName.localeCompare(b.displayName)); + return [...tests].sort((a,b) => (statusOrder[a.status]||9) - (statusOrder[b.status]||9)); +} + +function computeDisplayGroups() { + if (groupMode === 'namespace') { + const map = {}; + groups.forEach(function(g) { + const ns = g.namespace || '(no namespace)'; + if (!map[ns]) map[ns] = {label: ns, tests: [], className: null, namespace: ns}; + g.tests.forEach(function(t) { map[ns].tests.push(t); }); + }); + return Object.values(map); + } + if (groupMode === 'status') { + const buckets = {Failed:[],Passed:[],Skipped:[],Cancelled:[]}; + groups.forEach(function(g) { + g.tests.forEach(function(t) { + if (t.status==='failed'||t.status==='error'||t.status==='timedOut') buckets.Failed.push(t); + else if (t.status==='passed') buckets.Passed.push(t); + else if (t.status==='skipped') buckets.Skipped.push(t); + else buckets.Cancelled.push(t); + }); + }); + var out = []; + ['Failed','Passed','Skipped','Cancelled'].forEach(function(k) { + if (buckets[k].length) out.push({label: k, tests: buckets[k], className: null}); + }); + return out; + } + return groups.map(function(g) { return {label: g.className, tests: g.tests, className: g.className, namespace: g.namespace}; }); +} + +// Feature 6: Diff-friendly error formatting +function formatAssertionMessage(msg) { + if (!msg) return ''; + let s = esc(msg); + // Pattern: "Expected: X\nActual: Y" or "Expected: X\r\nActual: Y" + s = s.replace(/^(Expected:\s*)(.+)$/gm, '$1$2'); + s = s.replace(/^(Actual:\s*)(.+)$/gm, '$1$2'); + // Pattern: "expected X but was Y" + s = s.replace(/(expected\s+)(.+?)(\s+but was\s+)(.+)/gi, '$1$2$3$4'); + return s; +} + +// Feature 5: Link icon SVG +const linkIcon = ''; + +const arrow = ''; + +function fmtTime(iso) { + if (!iso) return '—'; + const d = new Date(iso); + return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3}); +} + +const copyIcon = ''; +const checkIcon = ''; + +function wrapPre(content, cls) { + return '
' + content + '
'; +} + +function renderDetail(t) { + let h = ''; + + // Summary info row + h += '
'; + h += 'Started ' + fmtTime(t.startTime) + ''; + h += 'Ended ' + fmtTime(t.endTime) + ''; + h += 'Duration ' + fmt(t.durationMs) + ''; + if (t.retryAttempt > 0) { + h += 'Retry #'+t.retryAttempt+''; + } + h += '
'; + + if (t.exception) { + h += '
Exception
'; + h += wrapPre(esc(t.exception.type) + ': ' + formatAssertionMessage(t.exception.message), 'err'); + if (t.exception.stackTrace) h += wrapPre(esc(t.exception.stackTrace), 'stack'); + let inner = t.exception.innerException; + while (inner) { + h += '
Inner Exception
'; + h += wrapPre(esc(inner.type) + ': ' + formatAssertionMessage(inner.message), 'err'); + if (inner.stackTrace) h += wrapPre(esc(inner.stackTrace), 'stack'); + inner = inner.innerException; + } + h += '
'; + } + if (t.skipReason) { + h += '
Skip Reason
'; + h += wrapPre(esc(t.skipReason)) + '
'; + } + if (t.output) { + h += '
' + tlArrow + 'Standard Output
'; + h += '
' + wrapPre(esc(t.output)) + '
'; + } + if (t.errorOutput) { + h += '
' + tlArrow + 'Error Output
'; + h += '
' + wrapPre(esc(t.errorOutput), 'err') + '
'; + } + if (t.categories && t.categories.length > 0) { + h += '
Categories
'; + t.categories.forEach(c => { h += ''+esc(c)+''; }); + h += '
'; + } + if (t.customProperties && t.customProperties.length > 0) { + h += '
Properties
'; + t.customProperties.forEach(p => { h += ''+esc(p.key)+'='+esc(p.value)+''; }); + h += '
'; + } + if (t.filePath) { + h += '
'+esc(t.filePath); + if (t.lineNumber) h += ':'+t.lineNumber; + h += '
'; + } + if (t.traceId && t.spanId && spansByTrace[t.traceId]) h += renderTrace(t.traceId, t.spanId); + return h; +} + +// Collect descendants of a span within a trace +function getDescendants(traceSpans, rootId) { + const children = {}; + traceSpans.forEach(s => { + if (s.parentSpanId) { + if (!children[s.parentSpanId]) children[s.parentSpanId] = []; + children[s.parentSpanId].push(s.spanId); + } + }); + const included = new Set(); + function walk(sid) { + if (included.has(sid)) return; + included.add(sid); + (children[sid] || []).forEach(walk); + } + walk(rootId); + return traceSpans.filter(s => included.has(s.spanId)); +} + +// Render a span waterfall from a filtered list of spans +function renderSpanRows(sp, uid) { + if (!sp || !sp.length) return ''; + const mn = Math.min(...sp.map(s => s.startTimeMs)); + const mx = Math.max(...sp.map(s => s.startTimeMs + s.durationMs)); + const dur = mx - mn || 1; + const idSet = new Set(sp.map(s => s.spanId)); + const depth = {}; + function gd(s) { + if (depth[s.spanId] !== undefined) return depth[s.spanId]; + if (!s.parentSpanId || !bySpanId[s.parentSpanId] || !idSet.has(s.parentSpanId)) { depth[s.spanId] = 0; return 0; } + depth[s.spanId] = gd(bySpanId[s.parentSpanId]) + 1; return depth[s.spanId]; + } + sp.forEach(gd); + const sorted = [...sp].sort((a, b) => a.startTimeMs - b.startTimeMs); + let h = '
'; + sorted.forEach((s, i) => { + const d = depth[s.spanId] || 0; + const l = ((s.startTimeMs - mn) / dur * 100).toFixed(2); + const w = Math.max((s.durationMs / dur * 100), .5).toFixed(2); + const cls = s.status === 'Error' ? 'err' : s.status === 'Ok' ? 'ok' : 'unk'; + h += '
'; + h += ''; + h += '
'; + h += '' + esc(s.name) + ''; + h += '' + fmt(s.durationMs) + ''; + h += '
'; + let ex = '
'; + ex += 'Source: ' + esc(s.source) + ' · Kind: ' + esc(s.kind); + if (s.tags && s.tags.length) { ex += '
Tags: '; s.tags.forEach(t => { ex += esc(t.key) + '=' + esc(t.value) + ' '; }); } + if (s.events && s.events.length) { ex += '
Events: '; s.events.forEach(e => { ex += esc(e.name) + ' '; if (e.tags) e.tags.forEach(t => { ex += esc(t.key) + '=' + esc(t.value) + ' '; }); }); } + ex += '
'; + h += ex; + }); + h += '
'; + return h; +} + +// Per-test trace: include parent suite span for context, then test case span + its descendants +function renderTrace(tid, rootSpanId) { + const allSpans = spansByTrace[tid]; + if (!allSpans || !allSpans.length) return ''; + const sp = getDescendants(allSpans, rootSpanId); + if (!sp.length) return ''; + // Include the parent suite span so the test bar is shown relative to the class duration + const root = bySpanId[rootSpanId]; + if (root && root.parentSpanId && bySpanId[root.parentSpanId]) { + const parent = bySpanId[root.parentSpanId]; + if (!sp.some(s => s.spanId === parent.spanId)) { + sp.unshift(parent); + } + } + return '
Trace Timeline
' + renderSpanRows(sp, 't-' + rootSpanId) + '
'; +} + +const tlArrow = ''; + +// Suite-level trace: test suite span + non-test-case children (hooks, setup, teardown) +function renderClassSummary(g, ft) { + // Compute start/end from suite span if available, else from tests + const suite = suiteSpanByClass[g.className]; + let startIso = null, endIso = null, durMs = 0; + if (suite) { + // Suite span times are relative ms — use the earliest test's startTime as anchor + durMs = suite.durationMs; + } + // Derive from test timestamps + let minStart = null, maxEnd = null; + ft.forEach(function(t){ + if (t.startTime) { + const s = new Date(t.startTime); + if (!minStart || s < minStart) minStart = s; + } + if (t.endTime) { + const e = new Date(t.endTime); + if (!maxEnd || e > maxEnd) maxEnd = e; + } + }); + startIso = minStart; + endIso = maxEnd; + if (!durMs && minStart && maxEnd) durMs = maxEnd - minStart; + + let h = '
'; + h += 'Started ' + (startIso ? fmtTime(startIso.toISOString()) : '\u2014') + ''; + h += 'Ended ' + (endIso ? fmtTime(endIso.toISOString()) : '\u2014') + ''; + h += 'Duration ' + fmt(durMs) + ''; + h += 'Tests ' + ft.length + ''; + h += '
'; + return h; +} + +function renderSuiteTrace(className) { + const suite = suiteSpanByClass[className]; + if (!suite) return ''; + const allSpans = spansByTrace[suite.traceId]; + if (!allSpans) return ''; + const all = getDescendants(allSpans, suite.spanId); + const testCaseIds = new Set(); + all.forEach(s => { if (s.name.startsWith('test case')) testCaseIds.add(s.spanId); }); + const tcDescendants = new Set(); + testCaseIds.forEach(id => { getDescendants(all, id).forEach(s => { if (s.spanId !== id) tcDescendants.add(s.spanId); }); }); + const filtered = all.filter(s => !tcDescendants.has(s.spanId) && !testCaseIds.has(s.spanId)); + // Include parent spans (assembly, session) for context + let ancestor = suite.parentSpanId ? bySpanId[suite.parentSpanId] : null; + while (ancestor) { + if (!filtered.some(s => s.spanId === ancestor.spanId)) filtered.unshift(ancestor); + ancestor = ancestor.parentSpanId ? bySpanId[ancestor.parentSpanId] : null; + } + if (filtered.length <= 1) return ''; + return '
' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
'; +} + +// Global timeline: session + assembly + suite spans +function renderGlobalTimeline() { + const topSpans = spans.filter(s => s.name.startsWith('test session') || s.name.startsWith('test assembly') || s.name.startsWith('test suite')); + if (!topSpans.length) return ''; + return '
' + tlArrow + 'Execution Timeline
' + renderSpanRows(topSpans, 'global') + '
'; +} + +function renderFailedSection() { + const sec = document.getElementById('failedSection'); + if (!sec) return; + const failed = []; + groups.forEach(function(g){ + g.tests.forEach(function(t){ + if (t.status==='failed'||t.status==='error'||t.status==='timedOut') failed.push({t:t,cls:g.className}); + }); + }); + if (!failed.length) { sec.innerHTML=''; return; } + let h = '
'+tlArrow+' Failed Tests ('+failed.length+')
'; + failed.forEach(function(f){ + const errMsg = f.t.exception ? (f.t.exception.type+': '+f.t.exception.message) : ''; + const truncErr = errMsg.length > 120 ? errMsg.substring(0,120)+'…' : errMsg; + h += '
'; + h += ''+esc(f.t.status)+''; + h += '
'+esc(f.t.displayName)+'
'; + h += '
'+esc(f.cls)+'
'; + if (truncErr) h += ''+esc(truncErr)+''; + h += ''+fmt(f.t.durationMs)+''; + h += '
'; + }); + h += '
'; + sec.innerHTML = h; +} + +function renderSlowestSection() { + const sec = document.getElementById('slowestSection'); + if (!sec) return; + const all = []; + groups.forEach(function(g){ + g.tests.forEach(function(t){ all.push({t:t,cls:g.className}); }); + }); + all.sort(function(a,b){ return b.t.durationMs - a.t.durationMs; }); + const top = all.slice(0,10); + if (!top.length) { sec.innerHTML=''; return; } + const maxMs = top[0].t.durationMs || 1; + let h = '
'+tlArrow+' Top 10 Slowest Tests
'; + top.forEach(function(f,i){ + const pct = Math.max((f.t.durationMs/maxMs)*100,1).toFixed(1); + h += '
'; + h += '#'+(i+1)+''; + h += '
'+esc(f.t.displayName)+'
'; + h += '
'+esc(f.cls)+'
'; + h += '
'; + h += ''+fmt(f.t.durationMs)+''; + h += '
'; + }); + h += '
'; + sec.innerHTML = h; +} + +function render() { + let total = 0; + let html = ''; + const displayGroups = computeDisplayGroups(); + const limited = displayGroups.slice(0, renderLimit); + limited.forEach((g,gi)=>{ + const ft = sortTests(g.tests.filter(matchesFilter)); + if (!ft.length) return; + total += ft.length; + const fail = ft.some(t=>t.status==='failed'||t.status==='error'||t.status==='timedOut'); + const open = fail || searchText; + const c = {p:0,f:0,s:0}; + ft.forEach(t=>{ + if(t.status==='passed')c.p++; + else if(t.status==='failed'||t.status==='error'||t.status==='timedOut')c.f++; + else if(t.status==='skipped')c.s++; + }); + html += '
'; + html += '
'; + html += '
'; + html += arrow; + html += ''+(searchText?highlight(g.label,searchText):esc(g.label))+''; + html += ''; + if(c.p) html += ''+c.p+''; + if(c.f) html += ''+c.f+''; + if(c.s) html += ''+c.s+''; + html += ''+ft.length+''; + html += '
'; + html += '
'; + if (groupMode === 'class') { + html += renderClassSummary(g, ft); + html += renderSuiteTrace(g.className); + } + ft.forEach((t,ti)=>{ + html += '
'; + html += ''+esc(t.status)+''; + html += ''+(searchText?highlight(t.displayName,searchText):esc(t.displayName))+''; + if(t.retryAttempt>0) html += 'retry '+t.retryAttempt+''; + html += ''; + html += ''+fmt(t.durationMs)+''; + html += '
'; + html += '
'; + html += renderDetail(t); + html += '
'; + }); + html += '
'; + }); + if (displayGroups.length > renderLimit) { + html += '
Loading more\u2026
'; + } + container.innerHTML = html; + observeSentinel(); + filterSummary.textContent = (activeFilter!=='all'||searchText) + ? 'Showing '+total+' of '+data.summary.total+' tests' : ''; + kbIdx = -1; +} + +function syncHash() { + const p = []; + if (activeFilter !== 'all') p.push('filter=' + encodeURIComponent(activeFilter)); + if (sortMode !== 'default') p.push('sort=' + encodeURIComponent(sortMode)); + if (searchText) p.push('search=' + encodeURIComponent(searchText)); + if (groupMode !== 'class') p.push('group=' + encodeURIComponent(groupMode)); + history.replaceState(null, '', p.length ? '#' + p.join('&') : location.pathname); +} + +function loadFromHash() { + const h = location.hash; + if (!h || h.length < 2) return; + const raw = h.substring(1); + if (raw.startsWith('test-')) return; // deep-link takes priority + const pairs = raw.split('&'); + pairs.forEach(function(pair) { + const eq = pair.indexOf('='); + if (eq < 0) return; + const k = decodeURIComponent(pair.substring(0, eq)); + const v = decodeURIComponent(pair.substring(eq + 1)); + if (k === 'filter') activeFilter = v; + else if (k === 'sort') sortMode = v; + else if (k === 'search') { searchText = v; searchInput.value = v; clearBtn.style.display = v ? 'block' : 'none'; } + else if (k === 'group') groupMode = v; + }); + // Sync button active states + filterBtns.querySelectorAll('.pill').forEach(function(b) { + const isActive = b.dataset.filter === activeFilter; + b.classList.toggle('active', isActive); + b.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + document.querySelectorAll('.sort-group .sort-btn').forEach(function(b) { + const isActive = b.dataset.sort === sortMode; + b.classList.toggle('active', isActive); + b.setAttribute('aria-checked', isActive ? 'true' : 'false'); + }); + document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(b) { + const isActive = b.dataset.group === groupMode; + b.classList.toggle('active', isActive); + b.setAttribute('aria-checked', isActive ? 'true' : 'false'); + }); +} + +let lazyObs = null; +function observeSentinel() { + if (lazyObs) lazyObs.disconnect(); + const el = document.getElementById('lazySentinel'); + if (!el) return; + lazyObs = new IntersectionObserver(function(entries) { + if (entries[0].isIntersecting) { renderLimit += 20; render(); } + }, {rootMargin: '200px'}); + lazyObs.observe(el); +} + +function scrollToTest(testId) { + const row = document.getElementById('test-' + testId); + if (!row) return; + // Expand parent group + const grp = row.closest('.grp'); + if (grp && !grp.classList.contains('open')) grp.classList.add('open'); + // Expand detail panel + const det = row.nextElementSibling; + if (det && det.classList.contains('t-detail') && !det.classList.contains('open')) det.classList.add('open'); + // Scroll into view + row.scrollIntoView({behavior:'smooth',block:'center'}); + // Flash highlight + row.classList.add('qa-highlight'); + setTimeout(function(){row.classList.remove('qa-highlight');},1500); + // Update hash + history.replaceState(null,'','#test-'+testId); +} + +function checkHash() { + const h = location.hash; + if (h && h.startsWith('#test-')) { + const testId = h.substring(6); + setTimeout(function(){scrollToTest(testId);},100); + } +} + +// Toggle for collapsible sections (timelines & output panels) +document.addEventListener('click',function(e){ + const tl = e.target.closest('.tl-toggle'); + if(tl){tl.parentElement.classList.toggle('tl-open');return;} + const ct = e.target.closest('.d-collapse-toggle'); + if(ct){ct.parentElement.classList.toggle('d-col-open');return;} +}); + +container.addEventListener('click',function(e){ + // Feature 5: Deep-link copy button + const lb = e.target.closest('.t-link-btn'); + if(lb){ + e.stopPropagation(); + const tid = lb.dataset.linkTid; + const url = location.origin + location.pathname + '#test-' + tid; + navigator.clipboard.writeText(url).then(function(){ + const tip = document.createElement('span'); + tip.className='t-link-copied';tip.textContent='Copied!'; + lb.appendChild(tip); + setTimeout(function(){tip.remove();},1200); + }); + return; + } + const hd = e.target.closest('.grp-hd'); + if(hd){const grp=hd.parentElement;grp.classList.toggle('open');hd.setAttribute('aria-expanded',grp.classList.contains('open')?'true':'false');return;} + const row = e.target.closest('.t-row'); + if(row){ + const det = container.querySelector('.t-detail[data-gi="'+row.dataset.gi+'"][data-ti="'+row.dataset.ti+'"]'); + if(det) det.classList.toggle('open'); + if(row.dataset.tid) history.replaceState(null,'','#test-'+row.dataset.tid); + return; + } + const sr = e.target.closest('.sp-row'); + if(sr){const nx=sr.nextElementSibling;if(nx&&nx.classList.contains('sp-extra'))nx.classList.toggle('open');} +}); + +filterBtns.addEventListener('click',function(e){ + const btn=e.target.closest('.pill'); + if(!btn)return; + filterBtns.querySelectorAll('.pill').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-pressed','false');}); + btn.classList.add('active'); + btn.setAttribute('aria-pressed','true'); + activeFilter=btn.dataset.filter; + renderLimit=20;render(); + syncHash(); +}); + +searchInput.addEventListener('input',function(){ + clearTimeout(debounceTimer); + clearBtn.style.display=searchInput.value?'block':'none'; + debounceTimer=setTimeout(function(){searchText=searchInput.value.trim();renderLimit=20;render();syncHash();},150); +}); +clearBtn.addEventListener('click',function(){searchInput.value='';clearBtn.style.display='none';searchText='';renderLimit=20;render();syncHash();}); + +// Feature 2: Expand/Collapse All +document.getElementById('expandAll').addEventListener('click',function(){ + container.querySelectorAll('.grp').forEach(function(g){g.classList.add('open');}); + container.querySelectorAll('.t-detail').forEach(function(d){d.classList.add('open');}); + document.querySelectorAll('.qa-section').forEach(function(s){s.classList.add('tl-open');}); +}); +document.getElementById('collapseAll').addEventListener('click',function(){ + container.querySelectorAll('.grp').forEach(function(g){g.classList.remove('open');}); + container.querySelectorAll('.t-detail').forEach(function(d){d.classList.remove('open');}); + document.querySelectorAll('.qa-section').forEach(function(s){s.classList.remove('tl-open');}); +}); + +// Feature 3: Sort Toggle +document.querySelectorAll('.sort-group .sort-btn').forEach(function(btn){ + btn.addEventListener('click',function(){ + document.querySelectorAll('.sort-group .sort-btn').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-checked','false');}); + btn.classList.add('active'); + btn.setAttribute('aria-checked','true'); + sortMode = btn.dataset.sort; + renderLimit=20;render(); + syncHash(); + }); +}); + +// Group-By Toggle +document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(btn){ + btn.addEventListener('click',function(){ + document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-checked','false');}); + btn.classList.add('active'); + btn.setAttribute('aria-checked','true'); + groupMode = btn.dataset.group; + renderLimit=20;render(); + syncHash(); + }); +}); + +// Quick-access section click delegation +document.addEventListener('click',function(e){ + const qi = e.target.closest('.qa-item'); + if(qi && qi.dataset.scrollTid){scrollToTest(qi.dataset.scrollTid);return;} + const cb = e.target.closest('.copy-btn'); + if(cb){ + const wrap = cb.closest('.d-pre-wrap'); + const pre = wrap && wrap.querySelector('.d-pre'); + if(pre){ + navigator.clipboard.writeText(pre.textContent).then(function(){ + cb.innerHTML = checkIcon; + cb.classList.add('copied'); + setTimeout(function(){cb.innerHTML=copyIcon;cb.classList.remove('copied');},1500); + }); + } + } +}); + +// Theme initialization +const savedTheme = localStorage.getItem('tunit-theme'); +const prefersDark = window.matchMedia('(prefers-color-scheme:dark)').matches; +const initTheme = savedTheme || (prefersDark ? 'dark' : 'light'); +document.documentElement.setAttribute('data-theme', initTheme); + +// Render global execution timeline (static, doesn't change with filters) +document.getElementById('globalTimeline').innerHTML = renderGlobalTimeline(); + +loadFromHash(); +render(); +renderFailedSection(); +renderSlowestSection(); +checkHash(); + +// Theme toggle handler +document.getElementById('themeToggle').addEventListener('click', function(){ + document.body.classList.add('theme-transition'); + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('tunit-theme', next); + setTimeout(function(){document.body.classList.remove('theme-transition');}, 350); +}); + +// ── Feature 7: Keyboard Navigation ────────────────── +function getVisibleRows(){return Array.from(container.querySelectorAll('.t-row'));} +function setKbFocus(idx){ + const rows = getVisibleRows(); + const old = container.querySelector('.t-row.kb-focus'); + if(old) old.classList.remove('kb-focus'); + if(idx<0||idx>=rows.length){kbIdx=-1;return;} + kbIdx=idx; + const row=rows[idx]; + row.classList.add('kb-focus'); + const grp=row.closest('.grp'); + if(grp&&!grp.classList.contains('open')) grp.classList.add('open'); + row.scrollIntoView({behavior:'smooth',block:'nearest'}); +} +function showKbHelp(){ + let ov=document.getElementById('kbOverlay'); + if(ov){ov.remove();return;} + ov=document.createElement('div');ov.id='kbOverlay';ov.className='kb-overlay'; + ov.setAttribute('role','dialog');ov.setAttribute('aria-modal','true');ov.setAttribute('aria-label','Keyboard shortcuts'); + ov.innerHTML='

Keyboard Shortcuts

' + +'
Next testj
' + +'
Previous testk
' + +'
Toggle detailEnter
' + +'
Close / clearEsc
' + +'
Focus search/
' + +'
This help?
' + +'' + +'
'; + ov.addEventListener('click',function(ev){if(ev.target===ov)ov.remove();}); + document.body.appendChild(ov); + document.getElementById('kbClose').focus(); + document.getElementById('kbClose').addEventListener('click',function(){ov.remove();}); +} +document.addEventListener('keydown',function(e){ + const tag=e.target.tagName; + if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT'){ + if(e.key==='Escape'){e.target.blur();if(e.target===searchInput){searchInput.value='';clearBtn.style.display='none';searchText='';renderLimit=20;render();syncHash();}} + return; + } + const ov=document.getElementById('kbOverlay'); + if(ov&&e.key==='Escape'){ov.remove();return;} + if(e.key==='j'){e.preventDefault();const rows=getVisibleRows();setKbFocus(Math.min(kbIdx+1,rows.length-1));return;} + if(e.key==='k'){e.preventDefault();setKbFocus(Math.max(kbIdx-1,0));return;} + if(e.key==='Enter'&&kbIdx>=0){ + e.preventDefault();const rows=getVisibleRows();const row=rows[kbIdx]; + if(row){const det=container.querySelector('.t-detail[data-gi="'+row.dataset.gi+'"][data-ti="'+row.dataset.ti+'"]');if(det)det.classList.toggle('open');} + return; + } + if(e.key==='Escape'){ + const focused=container.querySelector('.t-row.kb-focus'); + if(focused)focused.classList.remove('kb-focus'); + kbIdx=-1; + container.querySelectorAll('.t-detail.open').forEach(function(d){d.classList.remove('open');}); + return; + } + if(e.key==='/'){e.preventDefault();searchInput.focus();return;} + if(e.key==='?'){e.preventDefault();showKbHelp();return;} +}); + +// ── Feature 8: Sticky Mini-Header ─────────────────── +(function(){ + const dash=document.querySelector('.dash'); + const bar=document.getElementById('stickyBar'); + if(!dash||!bar)return; + const obs=new IntersectionObserver(function(entries){ + entries.forEach(function(en){bar.classList.toggle('visible',!en.isIntersecting);}); + },{threshold:0}); + obs.observe(dash); + var ssb=document.getElementById('stickySearchBtn'); + if(ssb) ssb.addEventListener('click',function(){searchInput.focus();searchInput.scrollIntoView({behavior:'smooth',block:'center'});}); +})(); + +// ── Feature 9: 100% Pass Celebration ──────────────── +if(data.summary.passed===data.summary.total&&data.summary.total>0){ + const dash=document.querySelector('.dash'); + if(dash) dash.classList.add('celebrate'); +} + +// ── Feature 10: Duration Histogram ────────────────── +(function(){ + const hist=document.getElementById('durationHist'); + if(!hist)return; + const durations=[]; + groups.forEach(function(g){g.tests.forEach(function(t){durations.push(t.durationMs);});}); + if(!durations.length)return; + const mn=Math.min.apply(null,durations); + const mx=Math.max.apply(null,durations); + if(mx<=mn){hist.innerHTML='
';return;} + const bins=10;const step=(mx-mn)/bins;const buckets=new Array(bins).fill(0); + durations.forEach(function(d){var i=Math.min(Math.floor((d-mn)/step),bins-1);buckets[i]++;}); + const maxB=Math.max.apply(null,buckets)||1; + let h=''; + for(var i=0;i'; + } + hist.innerHTML=h; +})(); +})(); +"""; + } +} diff --git a/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs b/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs new file mode 100644 index 0000000000..233059c1b0 --- /dev/null +++ b/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace TUnit.Engine.Reporters.Html; + +[JsonSerializable(typeof(ReportData))] +[JsonSerializable(typeof(ReportSummary))] +[JsonSerializable(typeof(ReportTestGroup))] +[JsonSerializable(typeof(ReportTestResult))] +[JsonSerializable(typeof(ReportExceptionData))] +[JsonSerializable(typeof(ReportKeyValue))] +[JsonSerializable(typeof(SpanData))] +[JsonSerializable(typeof(SpanEvent))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +internal sealed partial class HtmlReportJsonContext : JsonSerializerContext; diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs new file mode 100644 index 0000000000..93a0af9550 --- /dev/null +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -0,0 +1,628 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestHost; +using TUnit.Engine.Configuration; +using TUnit.Engine.Constants; +using TUnit.Engine.Framework; + +#pragma warning disable TPEXP + +namespace TUnit.Engine.Reporters.Html; + +internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver +{ + private string? _outputPath; + private readonly ConcurrentDictionary> _updates = []; + +#if NET + private ActivityCollector? _activityCollector; +#endif + + public async Task IsEnabledAsync() + { + var disableValue = Environment.GetEnvironmentVariable(EnvironmentConstants.DisableHtmlReporter); + if (disableValue is not null && + (disableValue.Equals("true", StringComparison.OrdinalIgnoreCase) || + disableValue.Equals("1", StringComparison.Ordinal) || + disableValue.Equals("yes", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return await extension.IsEnabledAsync(); + } + + public string Uid { get; } = $"{extension.Uid}HtmlReporter"; + + public string Version => extension.Version; + + public string DisplayName => extension.DisplayName; + + public string Description => extension.Description; + + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + var testNodeUpdateMessage = (TestNodeUpdateMessage)value; + _updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, _ => []).Enqueue(testNodeUpdateMessage); + return Task.CompletedTask; + } + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + + public Task BeforeRunAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_outputPath)) + { + _outputPath = GetDefaultOutputPath(); + } + +#if NET + _activityCollector = new ActivityCollector(); + _activityCollector.Start(); +#endif + return Task.CompletedTask; + } + + public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) + { + try + { +#if NET + _activityCollector?.Stop(); +#endif + + if (_updates.Count == 0) + { + return; + } + + var reportData = BuildReportData(); + var html = HtmlReportGenerator.GenerateHtml(reportData); + + if (string.IsNullOrEmpty(html)) + { + return; + } + + await WriteFileAsync(_outputPath!, html, cancellation); + + // GitHub Actions integration (artifact upload + step summary) + await TryGitHubIntegrationAsync(_outputPath!, cancellation); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: HTML report generation failed: {ex.Message}"); + } + } + + public string? Filter { get; set; } + + internal void SetOutputPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Output path cannot be null or empty", nameof(path)); + } + + _outputPath = path; + } + + private ReportData BuildReportData() + { + var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + var tunitVersion = typeof(HtmlReporter).Assembly.GetName().Version?.ToString() ?? "unknown"; + + // Get the last update with a final state for each test + var lastUpdates = new Dictionary(_updates.Count); + foreach (var kvp in _updates) + { + TestNodeUpdateMessage? lastFinal = null; + foreach (var update in kvp.Value) + { + var state = update.TestNode.Properties.SingleOrDefault(); + if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) + { + lastFinal = update; + } + } + + if (lastFinal != null) + { + lastUpdates[kvp.Key] = lastFinal; + } + } + + var summary = new ReportSummary(); + var groupsByClass = new Dictionary>(); + var groupNamespaces = new Dictionary(); + double overallStartMs = double.MaxValue; + double overallEndMs = double.MinValue; + + // Build span lookup to correlate traces with test results +#if NET + var spanLookup = _activityCollector?.GetTestSpanLookup(); +#else + var spanLookup = (Dictionary?)null; +#endif + + foreach (var kvp in lastUpdates) + { + var testNode = kvp.Value.TestNode; + + // Correlate trace/span IDs from collected activities + string? traceId = null, spanId = null; + if (spanLookup?.TryGetValue(kvp.Key, out var spanInfo) == true) + { + traceId = spanInfo.TraceId; + spanId = spanInfo.SpanId; + } + + // Track retry attempts by counting final-state updates. + // A non-retried test has exactly 1 final-state update; each retry adds another. + var retryAttempt = 0; + if (_updates.TryGetValue(kvp.Key, out var allUpdates)) + { + var finalStateCount = 0; + foreach (var update in allUpdates) + { + var state = update.TestNode.Properties.SingleOrDefault(); + if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) + { + finalStateCount++; + } + } + + if (finalStateCount > 1) + { + retryAttempt = finalStateCount - 1; + } + } + + var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt); + + AccumulateStatus(summary, testResult.Status); + + // Group by class name + var className = testResult.ClassName; + if (!groupsByClass.TryGetValue(className, out var list)) + { + list = []; + groupsByClass[className] = list; + } + + list.Add(testResult); + + // Track namespace + var testMethodIdentifier = testNode.Properties.AsEnumerable() + .OfType() + .FirstOrDefault(); + if (testMethodIdentifier != null && !groupNamespaces.ContainsKey(className)) + { + groupNamespaces[className] = testMethodIdentifier.Namespace; + } + + // Track overall timing + var timingProperty = testNode.Properties.AsEnumerable() + .OfType() + .FirstOrDefault(); + if (timingProperty?.GlobalTiming is { } globalTiming) + { + var startMs = globalTiming.StartTime.ToUnixTimeMilliseconds(); + var endMs = (globalTiming.StartTime + globalTiming.Duration).ToUnixTimeMilliseconds(); + if (startMs < overallStartMs) + { + overallStartMs = startMs; + } + + if (endMs > overallEndMs) + { + overallEndMs = endMs; + } + } + } + + var totalDurationMs = overallStartMs < double.MaxValue ? overallEndMs - overallStartMs : 0; + + // Build groups + var groups = new ReportTestGroup[groupsByClass.Count]; + var i = 0; + foreach (var kvp in groupsByClass) + { + var groupSummary = new ReportSummary(); + foreach (var test in kvp.Value) + { + AccumulateStatus(groupSummary, test.Status); + } + + groups[i++] = new ReportTestGroup + { + ClassName = kvp.Key, + Namespace = groupNamespaces.GetValueOrDefault(kvp.Key, ""), + Summary = groupSummary, + Tests = kvp.Value.ToArray() + }; + } + + // Collect spans + SpanData[]? spans = null; +#if NET + if (_activityCollector != null) + { + spans = _activityCollector.GetAllSpans(); + } +#endif + + var (commitSha, branch, prNumber, repoSlug) = GetCiContext(); + + return new ReportData + { + AssemblyName = assemblyName, + MachineName = Environment.MachineName, + Timestamp = DateTimeOffset.UtcNow.ToString("dd MMM yyyy, HH:mm:ss 'UTC'"), + TUnitVersion = tunitVersion, + OperatingSystem = RuntimeInformation.OSDescription, + RuntimeVersion = RuntimeInformation.FrameworkDescription, + Filter = Filter, + TotalDurationMs = totalDurationMs, + Summary = summary, + Groups = groups, + Spans = spans, + CommitSha = commitSha, + Branch = branch, + PullRequestNumber = prNumber, + RepositorySlug = repoSlug + }; + } + + private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug) GetCiContext() + { + if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") + { + return (null, null, null, null); + } + + var commitSha = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubSha); + var repoSlug = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository); + + // Branch: prefer GITHUB_HEAD_REF (set on PRs), fallback to GITHUB_REF (strip refs/heads/) + var branch = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubHeadRef); + if (string.IsNullOrEmpty(branch)) + { + var ghRef = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRef); + if (ghRef is not null && ghRef.StartsWith("refs/heads/", StringComparison.Ordinal)) + { + branch = ghRef.Substring("refs/heads/".Length); + } + } + + // PR number: parse from GITHUB_REF if it matches refs/pull/{n}/merge + string? prNumber = null; + var refValue = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRef); + if (refValue is not null && + refValue.StartsWith("refs/pull/", StringComparison.Ordinal) && + refValue.EndsWith("/merge", StringComparison.Ordinal)) + { + prNumber = refValue.Substring("refs/pull/".Length, refValue.Length - "refs/pull/".Length - "/merge".Length); + } + + return (commitSha, branch, prNumber, repoSlug); + } + + private static void AccumulateStatus(ReportSummary summary, string status) + { + summary.Total++; + switch (status) + { + case "passed": + summary.Passed++; + break; + case "failed" or "error": + summary.Failed++; + break; + case "skipped": + summary.Skipped++; + break; + case "timedOut": + summary.TimedOut++; + break; + case "cancelled": + summary.Cancelled++; + break; + } + } + + private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt) + { + IProperty? stateProperty = null; + TestMethodIdentifierProperty? testMethodIdentifier = null; + TimingProperty? timingProperty = null; + TestFileLocationProperty? fileLocation = null; + string? stdOut = null; + string? stdErr = null; + List? categories = null; + List? customProperties = null; + + foreach (var prop in testNode.Properties.AsEnumerable()) + { + switch (prop) + { + case TestNodeStateProperty when stateProperty is null: + stateProperty = prop; + break; + case TestMethodIdentifierProperty m: + testMethodIdentifier = m; + break; + case TimingProperty t: + timingProperty = t; + break; + case TestFileLocationProperty f: + fileLocation = f; + break; + case StandardOutputProperty o: + stdOut = o.StandardOutput; + break; + case StandardErrorProperty e: + stdErr = e.StandardError; + break; + case TestMetadataProperty meta: + if (string.IsNullOrEmpty(meta.Key)) + { + categories ??= []; + categories.Add(meta.Value); + } + else + { + customProperties ??= []; + customProperties.Add(new ReportKeyValue { Key = meta.Key, Value = meta.Value }); + } + break; + } + } + + var categoriesArray = categories?.ToArray(); + var customPropertiesArray = customProperties?.ToArray(); + + var className = testMethodIdentifier?.TypeName ?? "UnknownClass"; + var methodName = testMethodIdentifier?.MethodName ?? testNode.DisplayName; + + var (status, exception, skipReason) = ExtractStatus(stateProperty); + + var durationMs = timingProperty?.GlobalTiming.Duration.TotalMilliseconds ?? 0; + var startTime = timingProperty?.GlobalTiming.StartTime; + var endTime = startTime.HasValue ? startTime.Value + timingProperty!.GlobalTiming.Duration : (DateTimeOffset?)null; + + return new ReportTestResult + { + Id = testId, + DisplayName = testNode.DisplayName, + MethodName = methodName, + ClassName = className, + Status = status, + DurationMs = durationMs, + StartTime = startTime?.ToString("o"), + EndTime = endTime?.ToString("o"), + Exception = exception, + Output = stdOut, + ErrorOutput = stdErr, + Categories = categoriesArray is { Length: > 0 } ? categoriesArray : null, + CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, + FilePath = fileLocation?.FilePath, + LineNumber = fileLocation?.LineSpan.Start.Line, + SkipReason = skipReason, + RetryAttempt = retryAttempt, + TraceId = traceId, + SpanId = spanId + }; + } + + private static (string Status, ReportExceptionData? Exception, string? SkipReason) ExtractStatus(IProperty? stateProperty) + { + return stateProperty switch + { + PassedTestNodeStateProperty => ("passed", null, null), + FailedTestNodeStateProperty failed => ("failed", MapException(failed.Exception), null), + ErrorTestNodeStateProperty error => ("error", MapException(error.Exception), null), + TimeoutTestNodeStateProperty timeout => ("timedOut", MapException(timeout.Exception), null), + SkippedTestNodeStateProperty skipped => ("skipped", null, skipped.Explanation), +#pragma warning disable CS0618 + CancelledTestNodeStateProperty => ("cancelled", null, null), +#pragma warning restore CS0618 + InProgressTestNodeStateProperty => ("inProgress", null, null), + _ => ("unknown", null, null) + }; + } + + private static ReportExceptionData? MapException(Exception? ex) + { + if (ex is null) + { + return null; + } + + return new ReportExceptionData + { + Type = ex.GetType().FullName ?? ex.GetType().Name, + Message = ex.Message, + StackTrace = ex.StackTrace, + InnerException = MapException(ex.InnerException) + }; + } + + private static string GetDefaultOutputPath() + { + var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); + var os = GetShortOsName(); + var tfm = GetShortFrameworkName(); + return Path.GetFullPath(Path.Combine("TestResults", $"{sanitizedName}-{os}-{tfm}-report.html")); + } + + private static string GetShortOsName() + { +#if NET + if (OperatingSystem.IsWindows()) return "windows"; + if (OperatingSystem.IsLinux()) return "linux"; + if (OperatingSystem.IsMacOS()) return "macos"; +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "windows"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macos"; +#endif + return "unknown"; + } + + private static string GetShortFrameworkName() + { + // RuntimeInformation.FrameworkDescription returns e.g. ".NET 10.0.0" or ".NET Framework 4.8.0" + var desc = RuntimeInformation.FrameworkDescription; + if (desc.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase)) + { + var version = desc.Substring(".NET Framework ".Length).Trim(); + var dotIndex = version.IndexOf('.'); + if (dotIndex > 0) + { + var secondDot = version.IndexOf('.', dotIndex + 1); + if (secondDot > 0) version = version.Substring(0, secondDot); + } + return $"net{version.Replace(".", "")}"; + } + + if (desc.StartsWith(".NET ", StringComparison.OrdinalIgnoreCase)) + { + var version = desc.Substring(".NET ".Length).Trim(); + var dotIndex = version.IndexOf('.'); + if (dotIndex > 0) + { + var secondDot = version.IndexOf('.', dotIndex + 1); + if (secondDot > 0) version = version.Substring(0, secondDot); + } + return $"net{version}"; + } + + return "unknown"; + } + + private static async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + const int maxAttempts = EngineDefaults.FileWriteMaxAttempts; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { +#if NET + await File.WriteAllTextAsync(path, content, Encoding.UTF8, cancellationToken); +#else + File.WriteAllText(path, content, Encoding.UTF8); +#endif + Console.WriteLine($"HTML test report written to: {path}"); + return; + } + catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) + { + var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1); + var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs); + var delay = (int)(baseDelay + jitter); + + Console.WriteLine($"HTML report file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})"); + await Task.Delay(delay, cancellationToken); + } + } + + Console.WriteLine($"Failed to write HTML test report to: {path} after {maxAttempts} attempts"); + } + + private static bool IsFileLocked(IOException exception) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var errorCode = exception.HResult & 0xFFFF; + return errorCode is 0x20 or 0x21; // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION + } + + // On POSIX, concurrent writers are less common; fallback to message heuristic + return exception.Message.Contains("being used by another process") || + exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); + } + + private static async Task TryGitHubIntegrationAsync(string filePath, CancellationToken cancellationToken) + { + if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") + { + return; + } + + var summaryPath = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubStepSummary); + var repo = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository); + var runId = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRunId); + + // Try in-process artifact upload if the runtime token is available + string? artifactId = null; + var runtimeToken = Environment.GetEnvironmentVariable(EnvironmentConstants.ActionsRuntimeToken); + var resultsUrl = Environment.GetEnvironmentVariable(EnvironmentConstants.ActionsResultsUrl); + var hasRuntimeToken = !string.IsNullOrEmpty(runtimeToken) && !string.IsNullOrEmpty(resultsUrl); + + if (!hasRuntimeToken) + { + Console.WriteLine("Tip: To enable automatic HTML report artifact upload, see https://tunit.dev/docs/guides/html-report#enabling-automatic-artifact-upload"); + } + + if (hasRuntimeToken) + { + try + { + artifactId = await GitHubArtifactUploader.UploadAsync(filePath, runtimeToken!, resultsUrl!, cancellationToken); + + if (artifactId is not null) + { + Console.WriteLine($"HTML report uploaded as GitHub artifact (ID: {artifactId})"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to upload HTML report artifact: {ex.Message}"); + } + } + + // Write to step summary + if (!string.IsNullOrEmpty(summaryPath)) + { + try + { + var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? Path.GetFileNameWithoutExtension(filePath); + string line; + + if (artifactId is not null && !string.IsNullOrEmpty(repo) && !string.IsNullOrEmpty(runId)) + { + line = $"\n\ud83d\udcca [{assemblyName} — View HTML Report](https://github.com/{repo}/actions/runs/{runId}/artifacts/{artifactId})\n"; + } + else + { + line = $"\n\ud83d\udcca **{assemblyName}** HTML report was generated — [Enable automatic artifact upload](https://tunit.dev/docs/guides/html-report#enabling-automatic-artifact-upload)\n"; + } + +#if NET + await File.AppendAllTextAsync(summaryPath, line, cancellationToken); +#else + File.AppendAllText(summaryPath, line); +#endif + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to write GitHub step summary: {ex.Message}"); + } + } + } +} diff --git a/TUnit.Engine/Reporters/HtmlReporter.cs b/TUnit.Engine/Reporters/HtmlReporter.cs deleted file mode 100644 index 972108b938..0000000000 --- a/TUnit.Engine/Reporters/HtmlReporter.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System.Collections.Concurrent; -using System.Net; -using System.Reflection; -using System.Text; -using Microsoft.Testing.Platform.Extensions; -using Microsoft.Testing.Platform.Extensions.Messages; -using Microsoft.Testing.Platform.Extensions.TestHost; -using TUnit.Engine.Framework; - -namespace TUnit.Engine.Reporters; - -public class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver -{ - private string _outputPath = null!; - private bool _isEnabled; - - public async Task IsEnabledAsync() - { - if (!_isEnabled) - { - return false; - } - - if (string.IsNullOrEmpty(_outputPath)) - { - _outputPath = GetDefaultOutputPath(); - } - - return await extension.IsEnabledAsync(); - } - - internal void Enable() - { - _isEnabled = true; - } - - public string Uid { get; } = $"{extension.Uid}HtmlReporter"; - - public string Version => extension.Version; - - public string DisplayName => extension.DisplayName; - - public string Description => extension.Description; - - private readonly ConcurrentDictionary> _updates = []; - - public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) - { - var testNodeUpdateMessage = (TestNodeUpdateMessage)value; - - _updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, []).Add(testNodeUpdateMessage); - - return Task.CompletedTask; - } - - public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; - - public Task BeforeRunAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) - { - if (!_isEnabled || _updates.Count == 0) - { - return; - } - - // Get the last update for each test - var lastUpdates = new Dictionary(_updates.Count); - foreach (var kvp in _updates) - { - if (kvp.Value.Count > 0) - { - lastUpdates[kvp.Key] = kvp.Value[kvp.Value.Count - 1]; - } - } - - var htmlContent = GenerateHtml(lastUpdates); - - if (string.IsNullOrEmpty(htmlContent)) - { - return; - } - - await WriteFileAsync(_outputPath, htmlContent, cancellation); - } - - public string? Filter { get; set; } - - internal void SetOutputPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("Output path cannot be null or empty", nameof(path)); - } - - _outputPath = path; - } - - private string GenerateHtml(Dictionary lastUpdates) - { - var passedCount = 0; - var failedCount = 0; - var skippedCount = 0; - var otherCount = 0; - - foreach (var kvp in lastUpdates) - { - var stateProperty = kvp.Value.TestNode.Properties.AsEnumerable() - .FirstOrDefault(p => p is TestNodeStateProperty); - - switch (stateProperty) - { - case PassedTestNodeStateProperty: - passedCount++; - break; - case FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty: - failedCount++; - break; - case SkippedTestNodeStateProperty: - skippedCount++; - break; - default: - otherCount++; - break; - } - } - - var totalCount = lastUpdates.Count; - var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; - - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine($"Test Report - {WebUtility.HtmlEncode(assemblyName)}"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("
"); - - // Header - sb.AppendLine($"

Test Report: {WebUtility.HtmlEncode(assemblyName)}

"); - sb.AppendLine($"

Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

"); - - if (!string.IsNullOrEmpty(Filter)) - { - sb.AppendLine($"

Filter: {WebUtility.HtmlEncode(Filter)}

"); - } - - // Summary - sb.AppendLine("
"); - sb.AppendLine($"
{totalCount}Total
"); - sb.AppendLine($"
{passedCount}Passed
"); - sb.AppendLine($"
{failedCount}Failed
"); - sb.AppendLine($"
{skippedCount}Skipped
"); - - if (otherCount > 0) - { - sb.AppendLine($"
{otherCount}Other
"); - } - - sb.AppendLine("
"); - - // Test results table - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - - foreach (var kvp in lastUpdates) - { - var testNode = kvp.Value.TestNode; - - var testMethodIdentifier = testNode.Properties.AsEnumerable() - .OfType() - .FirstOrDefault(); - - var className = testMethodIdentifier?.TypeName; - var displayName = testNode.DisplayName; - var name = string.IsNullOrEmpty(className) ? displayName : $"{className}.{displayName}"; - - var stateProperty = testNode.Properties.AsEnumerable() - .FirstOrDefault(p => p is TestNodeStateProperty); - - var status = GetStatus(stateProperty); - var cssClass = GetStatusCssClass(stateProperty); - - var timingProperty = testNode.Properties.AsEnumerable() - .OfType() - .FirstOrDefault(); - - var duration = timingProperty?.GlobalTiming.Duration; - var durationText = duration.HasValue ? FormatDuration(duration.Value) : "-"; - - var details = GetDetails(stateProperty); - - sb.AppendLine($""); - sb.AppendLine($""); - sb.AppendLine($""); - sb.AppendLine($""); - sb.AppendLine($""); - sb.AppendLine(""); - } - - sb.AppendLine(""); - sb.AppendLine("
TestStatusDurationDetails
{WebUtility.HtmlEncode(name)}{WebUtility.HtmlEncode(status)}{WebUtility.HtmlEncode(durationText)}{(string.IsNullOrEmpty(details) ? "" : $"
{WebUtility.HtmlEncode(details)}
")}
"); - - sb.AppendLine("
"); // container - sb.AppendLine(""); - sb.AppendLine(""); - - return sb.ToString(); - } - - private static string GetCss() - { - return """ - * { margin: 0; padding: 0; box-sizing: border-box; } - body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 20px; } - .container { max-width: 1200px; margin: 0 auto; } - h1 { margin-bottom: 8px; color: #1a1a1a; } - .timestamp { color: #666; margin-bottom: 4px; } - .filter { color: #666; margin-bottom: 16px; } - .filter code { background: #e8e8e8; padding: 2px 6px; border-radius: 3px; } - .summary { display: flex; gap: 16px; margin: 20px 0; flex-wrap: wrap; } - .summary-item { background: #fff; border-radius: 8px; padding: 16px 24px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 120px; } - .summary-item .count { display: block; font-size: 2em; font-weight: bold; } - .summary-item .label { display: block; font-size: 0.9em; color: #666; margin-top: 4px; } - .summary-item.total { border-top: 4px solid #2196F3; } - .summary-item.passed { border-top: 4px solid #4CAF50; } - .summary-item.failed { border-top: 4px solid #F44336; } - .summary-item.skipped { border-top: 4px solid #FF9800; } - .summary-item.other { border-top: 4px solid #9E9E9E; } - table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 20px; } - thead th { background: #fafafa; padding: 12px 16px; text-align: left; font-weight: 600; border-bottom: 2px solid #eee; } - tbody td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; vertical-align: top; } - tbody tr:last-child td { border-bottom: none; } - .test-name { word-break: break-word; } - .badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.85em; font-weight: 600; } - .badge.row-passed { background: #E8F5E9; color: #2E7D32; } - .badge.row-failed { background: #FFEBEE; color: #C62828; } - .badge.row-skipped { background: #FFF3E0; color: #E65100; } - .badge.row-other { background: #F5F5F5; color: #616161; } - .details pre { white-space: pre-wrap; word-break: break-word; font-size: 0.85em; background: #f8f8f8; padding: 8px; border-radius: 4px; max-height: 200px; overflow: auto; } - .duration { white-space: nowrap; } - """; - } - - private static string GetStatus(IProperty? stateProperty) - { - return stateProperty switch - { - PassedTestNodeStateProperty => "Passed", - FailedTestNodeStateProperty => "Failed", - ErrorTestNodeStateProperty => "Error", - TimeoutTestNodeStateProperty => "Timed Out", - SkippedTestNodeStateProperty => "Skipped", -#pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete - CancelledTestNodeStateProperty => "Cancelled", -#pragma warning restore CS0618 - InProgressTestNodeStateProperty => "In Progress", - _ => "Unknown" - }; - } - - private static string GetStatusCssClass(IProperty? stateProperty) - { - return stateProperty switch - { - PassedTestNodeStateProperty => "row-passed", - FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty => "row-failed", - SkippedTestNodeStateProperty => "row-skipped", - _ => "row-other" - }; - } - - private static string GetDetails(IProperty? stateProperty) - { - return stateProperty switch - { - FailedTestNodeStateProperty failed => failed.Exception?.ToString() ?? "Test failed", - ErrorTestNodeStateProperty error => error.Exception?.ToString() ?? "Test error", - TimeoutTestNodeStateProperty timeout => timeout.Explanation ?? "Test timed out", - SkippedTestNodeStateProperty skipped => skipped.Explanation ?? "", -#pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete - CancelledTestNodeStateProperty => "Test was cancelled", -#pragma warning restore CS0618 - _ => "" - }; - } - - private static string FormatDuration(TimeSpan duration) - { - if (duration.TotalMilliseconds < 1) - { - var microseconds = duration.Ticks / 10.0; - return $"{microseconds:F0}us"; - } - - if (duration.TotalSeconds < 1) - { - return $"{duration.TotalMilliseconds:F0}ms"; - } - - if (duration.TotalMinutes < 1) - { - return $"{duration.TotalSeconds:F2}s"; - } - - return $"{duration.TotalMinutes:F1}m"; - } - - private static string GetDefaultOutputPath() - { - var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; - return Path.Combine("TestResults", $"{assemblyName}-report.html"); - } - - private static async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken) - { - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - const int maxAttempts = 5; - - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { -#if NET - await File.WriteAllTextAsync(path, content, Encoding.UTF8, cancellationToken); -#else - File.WriteAllText(path, content, Encoding.UTF8); -#endif - Console.WriteLine($"HTML test report written to: {path}"); - return; - } - catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) - { - var baseDelay = 50 * Math.Pow(2, attempt - 1); - var jitter = Random.Shared.Next(0, 50); - var delay = (int)(baseDelay + jitter); - - Console.WriteLine($"HTML report file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})"); - await Task.Delay(delay, cancellationToken); - } - } - - Console.WriteLine($"Failed to write HTML test report to: {path} after {maxAttempts} attempts"); - } - - private static bool IsFileLocked(IOException exception) - { - var errorCode = exception.HResult & 0xFFFF; - return errorCode == 0x20 || errorCode == 0x21 || - exception.Message.Contains("being used by another process") || - exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); - } -} diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index e19d4588d7..7db0a93d73 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -46,7 +46,9 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func ac testContext.Execution.Result = null; testContext.TestStart = null; testContext.Execution.TestEnd = null; +#pragma warning disable CS0618 // Obsolete Timing API testContext.Timings.Clear(); +#pragma warning restore CS0618 continue; } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 84e252c927..70d0f0a553 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -89,7 +89,9 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca test.Context.Execution.Result = null; test.Context.TestStart = null; test.Context.Execution.TestEnd = null; +#pragma warning disable CS0618 // Obsolete Timing API test.Context.Timings.Clear(); +#pragma warning restore CS0618 TestContext.Current = test.Context; diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 0533484e53..f9c2388445 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -130,6 +130,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( new("tunit.test.class", testDetails.ClassType.FullName), new("tunit.test.method", testDetails.MethodName), new("tunit.test.id", executableTest.Context.Id), + new("tunit.test.node_uid", testDetails.TestId), new("tunit.test.categories", testDetails.Categories.ToArray()) ]); } @@ -146,8 +147,36 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.RestoreExecutionContext(); - await Timings.Record("BeforeTest", executableTest.Context, - () => _hookExecutor.ExecuteBeforeTestHooksAsync(executableTest, cancellationToken)).ConfigureAwait(false); +#if NET + Activity? beforeTestActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + beforeTestActivity = TUnitActivitySource.StartActivity( + "hook: BeforeTest", + ActivityKind.Internal, + executableTest.Context.Activity?.Context ?? default); + } +#endif + try + { + await _hookExecutor.ExecuteBeforeTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false); + } + catch +#if NET + (Exception ex) +#endif + { +#if NET + TUnitActivitySource.RecordException(beforeTestActivity, ex); +#endif + throw; + } + finally + { +#if NET + TUnitActivitySource.StopActivity(beforeTestActivity); +#endif + } // Late stage test start receivers run after instance-level hooks (default behavior) await _eventReceiverOrchestrator.InvokeTestStartEventReceiversAsync(executableTest.Context, cancellationToken, EventReceiverStage.Late).ConfigureAwait(false); @@ -156,6 +185,16 @@ await Timings.Record("BeforeTest", executableTest.Context, // Only the test body is subject to the [Timeout] — hooks and data source // initialization run outside the timeout scope (fixes #4772) +#if NET + Activity? testBodyActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + testBodyActivity = TUnitActivitySource.StartActivity( + "test body", + ActivityKind.Internal, + executableTest.Context.Activity?.Context ?? default); + } +#endif try { var timeoutMessage = testTimeout.HasValue @@ -168,8 +207,21 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( cancellationToken, timeoutMessage).ConfigureAwait(false); } + catch +#if NET + (Exception ex) +#endif + { +#if NET + TUnitActivitySource.RecordException(testBodyActivity, ex); +#endif + throw; + } finally { +#if NET + TUnitActivitySource.StopActivity(testBodyActivity); +#endif executableTest.Context.Execution.TestEnd ??= DateTimeOffset.UtcNow; } @@ -194,10 +246,30 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( var earlyStageExceptions = await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, CancellationToken.None, EventReceiverStage.Early).ConfigureAwait(false); IReadOnlyList hookExceptions = []; - await Timings.Record("AfterTest", executableTest.Context, (Func)(async () => +#if NET + Activity? afterTestActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + afterTestActivity = TUnitActivitySource.StartActivity( + "hook: AfterTest", + ActivityKind.Internal, + executableTest.Context.Activity?.Context ?? default); + } +#endif + try { hookExceptions = await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, CancellationToken.None).ConfigureAwait(false); - })).ConfigureAwait(false); + } + finally + { +#if NET + if (hookExceptions.Count > 0 && afterTestActivity is not null) + { + afterTestActivity.SetStatus(ActivityStatusCode.Error); + } + TUnitActivitySource.StopActivity(afterTestActivity); +#endif + } // Late stage test end receivers run after instance-level hooks (default behavior) var lateStageExceptions = await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, CancellationToken.None, EventReceiverStage.Late).ConfigureAwait(false); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 5c75388a8f..078225bc4a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1621,6 +1621,8 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] public class Timing : <.Timing> { public Timing(string StepName, Start, End) { } @@ -2537,11 +2539,15 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index a25bd154dc..37ee51037d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1621,6 +1621,8 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] public class Timing : <.Timing> { public Timing(string StepName, Start, End) { } @@ -2537,11 +2539,15 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 68b8e6d296..f67222d32d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1621,6 +1621,8 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] public class Timing : <.Timing> { public Timing(string StepName, Start, End) { } @@ -2537,11 +2539,15 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 5092f73a19..092ae03e76 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1573,6 +1573,8 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] public class Timing : <.Timing> { public Timing(string StepName, Start, End) { } @@ -2487,11 +2489,15 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md new file mode 100644 index 0000000000..d9139d4b31 --- /dev/null +++ b/docs/docs/guides/html-report.md @@ -0,0 +1,128 @@ +--- +sidebar_position: 10 +--- + +# HTML Test Report + +TUnit automatically generates a rich HTML test report after every test run. No configuration is needed — the report is always on by default. + +## Report Location + +After running your tests, the report is written to: + +``` +TestResults/{AssemblyName}-{os}-{tfm}-report.html +``` + +For example: `TestResults/MyTests-linux-net10.0-report.html` + +The OS and runtime version are included automatically so that matrix builds (multiple platforms/TFMs) produce distinct files instead of overwriting each other. + +Open it in any modern browser. The report is fully self-contained (single HTML file) and works offline. + +## Features + +- **Summary Dashboard** — Total, passed, failed, skipped, and cancelled counts with a visual pass-rate donut chart +- **Expandable Test Details** — Click any test to see exception details, stack traces, stdout/stderr output, timing breakdown, categories, and custom properties +- **Search** — Type-ahead search to quickly filter tests by name, class, or category +- **Status Filters** — Filter by All, Passed, Failed, Skipped, or Cancelled +- **Class Grouping** — Tests are grouped by class with collapsible sections. Groups with failures auto-expand. +- **OpenTelemetry Trace Timeline** — When your tests use `System.Diagnostics.Activity`, a waterfall timeline shows nested spans with durations +- **Retry Support** — Tests with retries show the final result with a retry badge + +## Configuration + +### Custom Output Path + +```bash +dotnet run -- --report-html-filename my-custom-report.html +``` + +### Disable Report Generation + +Set the environment variable: + +```bash +export TUNIT_DISABLE_HTML_REPORTER=true +``` + +Accepts: `true`, `1`, `yes` (case-insensitive). + +### 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. + +## GitHub Actions Integration + +When TUnit detects it is running inside GitHub Actions (`GITHUB_ACTIONS=true`), it will: + +1. **Upload the report as a workflow artifact** (if the required runtime token is available) +2. **Add a link to the GitHub step summary** so you can find the report directly from the workflow run page + +### Enabling Automatic Artifact Upload + +GitHub Actions does not expose `ACTIONS_RUNTIME_TOKEN` to shell `run:` steps by default — it is only available inside JavaScript and Docker action handlers. To make it available to your test process, add **one** of the following steps **before** your test step. + +#### Option A: `actions/github-script` (recommended, first-party) + +```yaml +- name: Expose GitHub Actions Runtime + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']); + core.exportVariable('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']); + +- name: Run Tests + run: dotnet run --project MyTests +``` + +#### Option B: `crazy-max/ghaction-github-runtime` + +```yaml +- name: Expose GitHub Actions Runtime + uses: crazy-max/ghaction-github-runtime@v3 + +- name: Run Tests + run: dotnet run --project MyTests +``` + +#### Option C: Manual `upload-artifact` step + +If you prefer not to expose the runtime token, you can upload the report yourself: + +```yaml +- name: Run Tests + run: dotnet run --project MyTests + +- name: Upload HTML Test Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: TestReport + path: '**/*-report.html' +``` + +### Viewing the Report + +After the workflow run completes: + +1. Go to the workflow run page +2. Look for the artifact link in the step summary (Options A/B), or +3. Find the report in the **Artifacts** section at the bottom of the page (all options) + +## Troubleshooting + +### Report Not Generated + +- Check that `TUNIT_DISABLE_HTML_REPORTER` is not set in your environment +- Verify that the `TestResults/` directory is writable +- Check the console output for any warning messages about report generation failures + +### Large Test Suites + +For test suites with 1,000+ tests, the report uses client-side rendering from embedded JSON data. All search and filtering happens in the browser without page reloads. + +### Trace Timeline Not Showing + +The trace timeline requires `System.Diagnostics.Activity` spans to be active during the test run. If your tests don't create activities, the trace section won't appear. TUnit's internal activities are captured automatically on .NET 8.0+. diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index 88db8e0d89..d0e3e85b91 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -48,6 +48,18 @@ export TUNIT_GITHUB_REPORTER_STYLE=full **Equivalent to:** `--github-reporter-style` +### TUNIT_DISABLE_HTML_REPORTER + +Disables the HTML test report that is generated by default after every test run. + +```bash +export TUNIT_DISABLE_HTML_REPORTER=true +``` + +Accepts truthy values: `true`, `1`, `yes` (case-insensitive). + +**Use case:** When you don't need the HTML report or want to reduce disk I/O. The report is written to `TestResults/{AssemblyName}-report.html` by default. + ### TUNIT_DISABLE_JUNIT_REPORTER Disables the JUnit XML reporter. @@ -215,6 +227,7 @@ When the same setting is configured in multiple places, TUnit follows this prior | `TUNIT_DISABLE_GITHUB_REPORTER` | - | Disables GitHub reporter | | `TUNIT_DISABLE_JUNIT_REPORTER` | - | Disables JUnit reporter | | `TUNIT_ENABLE_JUNIT_REPORTER` | - | Enables JUnit reporter | +| `TUNIT_DISABLE_HTML_REPORTER` | - | Disables HTML report generation | | `JUNIT_XML_OUTPUT_PATH` | - | JUnit output path | | `TUNIT_MAX_PARALLEL_TESTS` | `--maximum-parallel-tests` | Max parallel tests | | `TUNIT_ENABLE_IDE_STREAMING` | - | Enable real-time IDE output streaming |