diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md index 325e24cd760..16c02002948 100644 --- a/docs/docs/guides/html-report.md +++ b/docs/docs/guides/html-report.md @@ -21,7 +21,7 @@ The OS and runtime version are included automatically so that matrix builds (mul Open it in any modern browser. The report is fully self-contained (single HTML file) and works offline. -A machine-readable JSON sidecar (`{AssemblyName}-{os}-{tfm}.tunit-report.json`) is written alongside the HTML report. It powers [report aggregation](/docs/guides/report-aggregation) — merging reports from multiple test projects into one — and can be disabled with `TUNIT_DISABLE_JSON_REPORT=true`. +A machine-readable JSON sidecar (`{AssemblyName}-{os}-{tfm}.tunit-report.json`) is written alongside the HTML report. It powers [report aggregation](/docs/guides/report-aggregation) — merging reports from multiple test projects into one — and can be disabled with `TUNIT_DISABLE_JSON_REPORT=true` or `context.Settings.Reporting.JsonReportEnabled = false`. Running many test projects and want **one combined report instead of one per project**? See [Aggregated Reports](/docs/guides/report-aggregation). @@ -53,6 +53,8 @@ export TUNIT_DISABLE_HTML_REPORTER=true Accepts: `true`, `1`, `yes` (case-insensitive). +For version-controlled project configuration, set `context.Settings.Reporting.HtmlReportEnabled = false` in a `[Before(HookType.TestDiscovery)]` hook instead. + ### 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. @@ -135,6 +137,8 @@ This is useful if you: The report file and the `GITHUB_STEP_SUMMARY` are still generated. +This can also be configured in code with `context.Settings.Reporting.ArtifactUploadEnabled = false`. + ### Viewing the Report After the workflow run completes: @@ -234,7 +238,7 @@ The collector uses **smart sampling**: spans from known test traces are fully re ### Report Not Generated -- Check that `TUNIT_DISABLE_HTML_REPORTER` is not set in your environment +- Check that `TUNIT_DISABLE_HTML_REPORTER` is not set and `context.Settings.Reporting.HtmlReportEnabled` is not `false` - Verify that the `TestResults/` directory is writable - Check the console output for any warning messages about report generation failures diff --git a/docs/docs/guides/report-aggregation.md b/docs/docs/guides/report-aggregation.md index 6cc1e5881b6..f399d78b215 100644 --- a/docs/docs/guides/report-aggregation.md +++ b/docs/docs/guides/report-aggregation.md @@ -12,7 +12,7 @@ Report aggregation merges all of that into **one combined HTML report and one Gi ## How It Works -1. Alongside every HTML report, TUnit writes a machine-readable sidecar: `{AssemblyName}-{os}-{tfm}.tunit-report.json`. This is on by default (disable with `TUNIT_DISABLE_JSON_REPORT=true`). +1. Alongside every HTML report, TUnit writes a machine-readable sidecar: `{AssemblyName}-{os}-{tfm}.tunit-report.json`. This is on by default (disable with `TUNIT_DISABLE_JSON_REPORT=true` or `context.Settings.Reporting.JsonReportEnabled = false`). 2. With aggregation enabled, each test process also copies its sidecar into a directory shared by all sibling processes. 3. As each process finishes, it takes a cross-process lock, reads *all* sidecars present so far, and regenerates the merged HTML report and the summary block. The last process to finish naturally leaves the complete aggregate — no process ever needs to know whether it is the last one. @@ -166,11 +166,11 @@ tunit-report merge --directory [options] | --- | --- | | `TUNIT_AGGREGATE_REPORTS` | Unset (default) — cooperative merge wherever a shared directory is resolvable (GitHub Actions, or explicit `TUNIT_AGGREGATE_DIR`); silently off otherwise. `defer` — persist sidecars + merged HTML only; no summary blocks (multi-step scenarios). `off` (also `false`/`0`/`no`/`disabled`/`none`) — no aggregation. | | `TUNIT_AGGREGATE_DIR` | Shared directory for sidecars and the merged report. Required outside GitHub Actions; optional override on GitHub Actions. | -| `TUNIT_DISABLE_JSON_REPORT` | Disables the JSON sidecar written next to the HTML report. Note: sidecars are what aggregation and `tunit-report` consume. | +| `TUNIT_DISABLE_JSON_REPORT` | Disables the JSON sidecar written next to the HTML report. Programmatic equivalent: `context.Settings.Reporting.JsonReportEnabled = false`. Note: sidecars are what aggregation and `tunit-report` consume. | ## Notes & Limitations -- Aggregation is driven by the HTML reporter's data pipeline — if you set `TUNIT_DISABLE_HTML_REPORTER`, no sidecars are produced and there is nothing to merge. +- Aggregation is driven by the HTML reporter's data pipeline — if you set `TUNIT_DISABLE_HTML_REPORTER` or `context.Settings.Reporting.HtmlReportEnabled = false`, no sidecars are produced and there is nothing to merge. - With cooperative mode (the default) across *multiple steps in the same job*, each step appends its own progressively-larger block (earlier steps' blocks can't be rewritten). Use `defer` + the tool for that layout, or `off` to restore per-suite blocks. - Suites are identified per assembly + OS + TFM, so multi-targeted projects appear as separate rows (e.g. `MyTests (.NET 8.0.x)` / `MyTests (.NET 9.0.x)`). - The GitHub step summary is capped at 1 MB by GitHub; the aggregated block replaces N per-suite blocks, so it usually *reduces* summary size. diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index 2e86de5f3e6..ef2d60f2ca9 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -137,7 +137,8 @@ Please note that for the coverage and trx report, you need to install [additiona --report-html (Deprecated) The HTML report is now generated by default. - Disable it with the TUNIT_DISABLE_HTML_REPORTER environment variable. + Disable it with TUNIT_DISABLE_HTML_REPORTER or set + context.Settings.Reporting.HtmlReportEnabled = false. --report-html-filename Path for the HTML test report file diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index 0f32f77ac35..f78d7e2b142 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -60,9 +60,11 @@ 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. +**Programmatic equivalent:** `context.Settings.Reporting.HtmlReportEnabled = false` + ### TUNIT_DISABLE_ARTIFACT_UPLOAD -Skips the autmoatic upload of the html report but still generates the files. +Skips automatic upload of the HTML report but still generates the files. ```bash export TUNIT_DISABLE_ARTIFACT_UPLOAD=true @@ -77,6 +79,20 @@ GitHub changed the server-side behaviour of `upload-artifacts` since v4 and hasn Forgejo and gitea don't implement this new artifact endpoint but the runners set `GITHUB_ACTIONS=true`. In this case it attempts to upload the report a few times until it eventually backs off after 30s. +**Programmatic equivalent:** `context.Settings.Reporting.ArtifactUploadEnabled = false` + +### TUNIT_DISABLE_JSON_REPORT + +Disables the machine-readable JSON sidecar written alongside the HTML report. + +```bash +export TUNIT_DISABLE_JSON_REPORT=true +``` + +Accepts truthy values: `true`, `1`, `yes` (case-insensitive). + +**Programmatic equivalent:** `context.Settings.Reporting.JsonReportEnabled = false` + ### TUNIT_DISABLE_JUNIT_REPORTER Disables the JUnit XML reporter. @@ -284,8 +300,9 @@ 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 | -| `TUNIT_DISABLE_ARTIFACT_UPLOAD` | - | Keeps the HTML report file but skips the GitHub Actions artifact upload | +| `TUNIT_DISABLE_HTML_REPORTER` | - | Disables HTML report generation (`context.Settings.Reporting.HtmlReportEnabled = false`) | +| `TUNIT_DISABLE_JSON_REPORT` | - | Disables the machine-readable JSON sidecar (`context.Settings.Reporting.JsonReportEnabled = false`) | +| `TUNIT_DISABLE_ARTIFACT_UPLOAD` | - | Keeps the HTML report file but skips the GitHub Actions artifact upload (`context.Settings.Reporting.ArtifactUploadEnabled = false`) | | `JUNIT_XML_OUTPUT_PATH` | - | JUnit output path | | `TUNIT_MAX_PARALLEL_TESTS` | `--maximum-parallel-tests` | Max parallel tests | | `TUNIT_EXECUTION_MODE` | `--reflection` | Selects source-generation (`sourcegeneration`/`aot`) or `reflection` execution mode | diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 9c08e4973e6..ad9ee85c755 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -15,6 +15,7 @@ Settings are organized into logical groups: - `Parallelism` — concurrent test execution limits - `Execution` — runtime behavior such as fail-fast - `Display` — output and display options +- `Reporting` — HTML report generation and publishing - `Mocks` — defaults for TUnit.Mocks when the package is referenced ## Usage @@ -33,6 +34,7 @@ public class TestSetup context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); context.Settings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); context.Settings.Execution.FailFast = true; + context.Settings.Reporting.HtmlReportEnabled = false; context.Settings.Mocks.DefaultMode = MockBehavior.Strict; return Task.CompletedTask; @@ -73,6 +75,16 @@ Settings are accessed exclusively through `context.Settings` in the discovery ho |---|---|---|---| | `FailFast` | `bool` | `false` | Cancels the remaining test run after the first test failure. | +### `context.Settings.Reporting` + +| Property | Type | Default | Description | +|---|---|---|---| +| `HtmlReportEnabled` | `bool` | `true` | Generates the HTML test report. | +| `JsonReportEnabled` | `bool` | `true` | Generates the machine-readable JSON sidecar used by report aggregation. | +| `ArtifactUploadEnabled` | `bool` | `true` | Uploads the HTML report as an artifact when supported by the CI environment. | + +The corresponding `TUNIT_DISABLE_HTML_REPORTER`, `TUNIT_DISABLE_JSON_REPORT`, and `TUNIT_DISABLE_ARTIFACT_UPLOAD` environment variables take precedence over these values. + ### `context.Settings.Mocks` Available when `TUnit.Mocks` is referenced. diff --git a/src/TUnit.Core/Settings/ReportingSettings.cs b/src/TUnit.Core/Settings/ReportingSettings.cs new file mode 100644 index 00000000000..a198a358b27 --- /dev/null +++ b/src/TUnit.Core/Settings/ReportingSettings.cs @@ -0,0 +1,28 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls built-in report generation and publishing. +/// +public sealed class ReportingSettings +{ + internal ReportingSettings() { } + + /// + /// Whether to generate the HTML test report. Default: true. + /// Precedence: TUNIT_DISABLE_HTML_REPORTER → TUnitSettings → built-in default. + /// + public bool HtmlReportEnabled { get; set; } = true; + + /// + /// Whether to generate the machine-readable JSON report sidecar. Default: true. + /// Precedence: TUNIT_DISABLE_JSON_REPORT → TUnitSettings → built-in default. + /// + public bool JsonReportEnabled { get; set; } = true; + + /// + /// Whether to upload the HTML report as an artifact when supported by the CI environment. + /// Default: true. + /// Precedence: TUNIT_DISABLE_ARTIFACT_UPLOAD → TUnitSettings → built-in default. + /// + public bool ArtifactUploadEnabled { get; set; } = true; +} diff --git a/src/TUnit.Core/Settings/TUnitSettings.cs b/src/TUnit.Core/Settings/TUnitSettings.cs index cc15574a89b..7ca50b384a0 100644 --- a/src/TUnit.Core/Settings/TUnitSettings.cs +++ b/src/TUnit.Core/Settings/TUnitSettings.cs @@ -38,4 +38,9 @@ internal TUnitSettings() { } /// Controls test run behavior. /// public ExecutionSettings Execution { get; } = new(); + + /// + /// Controls report generation and publishing. + /// + public ReportingSettings Reporting { get; } = new(); } diff --git a/src/TUnit.Engine/Reporters/Aggregation/ReportAggregator.cs b/src/TUnit.Engine/Reporters/Aggregation/ReportAggregator.cs index acba21e926d..72aac1d2ade 100644 --- a/src/TUnit.Engine/Reporters/Aggregation/ReportAggregator.cs +++ b/src/TUnit.Engine/Reporters/Aggregation/ReportAggregator.cs @@ -39,9 +39,9 @@ internal sealed class ReportAggregator private const string SidecarSearchPattern = "*" + ReportDataJson.SidecarExtension; private const string LockFileName = ".tunit-aggregate.lock"; - // Lock contention is expected (N processes finishing together, each holding the lock - // for a full merge), so wait far longer than the file-write retry defaults. - private const int LockMaxAttempts = 60; + // Bound lock contention to roughly ten seconds. Reporting must never hang test-suite + // completion; timed-out writers leave their sidecar for a later aggregate refresh. + private const int LockMaxAttempts = 30; private const int LockRetryDelayMs = 250; internal AggregationMode Mode { get; } @@ -130,12 +130,107 @@ internal string WriteSidecar(byte[] sidecarUtf8Json, string assemblyName, string { System.IO.Directory.CreateDirectory(Directory); - var fileName = $"{PathValidator.SanitizeFileName(assemblyName)}-{ShortHash(suiteSalt)}{ReportDataJson.SidecarExtension}"; - var path = Path.Combine(Directory, fileName); + var path = GetSidecarPath(assemblyName, suiteSalt); AtomicFile.WriteAllBytes(path, sidecarUtf8Json); return path; } + internal void WritePendingSidecar(byte[] sidecarUtf8Json, string assemblyName, string suiteSalt) + { + System.IO.Directory.CreateDirectory(Directory); + var path = GetPendingSidecarPath(assemblyName, suiteSalt); + AtomicFile.WriteAllBytes(path, sidecarUtf8Json); + } + + internal void DeletePendingSidecar(string assemblyName, string suiteSalt) + { + var path = GetPendingSidecarPath(assemblyName, suiteSalt); + File.Delete(path); + } + + internal string? ReadEffectiveSidecarGeneration(string assemblyName, string suiteSalt) + { + var pendingPath = GetPendingSidecarPath(assemblyName, suiteSalt); + var sidecarPath = File.Exists(pendingPath) + ? pendingPath + : GetSidecarPath(assemblyName, suiteSalt); + return File.Exists(sidecarPath) + ? ReportDataJson.GetPublicationGeneration(File.ReadAllBytes(sidecarPath)) + : null; + } + + internal void ExcludeSidecar(string assemblyName, string suiteSalt) + { + System.IO.Directory.CreateDirectory(Directory); + var currentGeneration = ReadEffectiveSidecarGeneration(assemblyName, suiteSalt); + AtomicFile.WriteAllText(GetExclusionMarkerPath(assemblyName, suiteSalt), currentGeneration ?? ""); + } + + internal void ExcludeSidecarIfGenerationMatches(string assemblyName, string suiteSalt, string? expectedGeneration) + { + System.IO.Directory.CreateDirectory(Directory); + var currentGeneration = ReadEffectiveSidecarGeneration(assemblyName, suiteSalt); + if (expectedGeneration is null + ? currentGeneration is not null + : !expectedGeneration.Equals(currentGeneration, StringComparison.Ordinal)) + { + return; + } + + AtomicFile.WriteAllText(GetExclusionMarkerPath(assemblyName, suiteSalt), currentGeneration ?? ""); + } + + internal void IncludeSidecar(string assemblyName, string suiteSalt) + { + File.Delete(GetExclusionMarkerPath(assemblyName, suiteSalt)); + } + + internal IDisposable BeginSidecarPublication(string assemblyName, string suiteSalt) + { + System.IO.Directory.CreateDirectory(Directory); + var lockPath = GetPublishingMarkerPath(assemblyName, suiteSalt); + return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + + internal IDisposable? TryAcquireSidecarPublication(string assemblyName, string suiteSalt) + { + try + { + return BeginSidecarPublication(assemblyName, suiteSalt); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + internal async Task AcquireSidecarPublicationAsync( + string assemblyName, + string suiteSalt, + CancellationToken cancellationToken) + { + System.IO.Directory.CreateDirectory(Directory); + return await AcquireFileLockAsync( + GetPublishingMarkerPath(assemblyName, suiteSalt), + cancellationToken, + "Warning: Report sidecar publication lock timed out; keeping the local per-suite report."); + } + + internal bool HasSidecarState(string assemblyName, string suiteSalt) + { + if (!System.IO.Directory.Exists(Directory)) + { + return false; + } + + var sidecarPath = GetSidecarPath(assemblyName, suiteSalt); + var pendingPath = GetPendingSidecarPath(assemblyName, suiteSalt); + return File.Exists(sidecarPath) + || File.Exists(pendingPath) + || File.Exists(sidecarPath + ReportDataJson.SidecarExclusionExtension) + || File.Exists(sidecarPath + ReportDataJson.SidecarPublishingExtension); + } + /// /// Reads every sidecar currently present in the shared directory. Unreadable or /// foreign files are skipped — a crashed sibling must not break the merge. @@ -154,12 +249,20 @@ internal List ReadAllSidecars() // as the tunit-report tool does. var seenDigests = new HashSet(StringComparer.Ordinal); using var sha = SHA256.Create(); - foreach (var file in System.IO.Directory.GetFiles(Directory, SidecarSearchPattern)) + var effectiveSidecars = ReportDataJson.SelectEffectiveSidecars( + System.IO.Directory.GetFiles(Directory, SidecarSearchPattern)); + foreach (var file in effectiveSidecars) { try { + if (ReportDataJson.IsSidecarPublicationInProgress(file)) + { + continue; + } + var bytes = File.ReadAllBytes(file); - if (seenDigests.Add(Convert.ToBase64String(sha.ComputeHash(bytes))) + if (!ReportDataJson.IsSidecarExcluded(file, bytes) + && seenDigests.Add(Convert.ToBase64String(sha.ComputeHash(bytes))) && ReportDataJson.TryDeserialize((ReadOnlyMemory)bytes) is { } data) { results.Add(data); @@ -178,14 +281,22 @@ internal List ReadAllSidecars() /// /// Acquires the cross-process aggregation lock. Every writer performs its whole /// read-merge-write cycle under this lock, so merges never interleave. Returns - /// when the lock cannot be acquired within the timeout; - /// callers should then skip merging (a later sibling will produce a fresher merge). + /// after a bounded wait so reporting cannot hang the run. /// internal async Task AcquireLockAsync(CancellationToken cancellationToken) { System.IO.Directory.CreateDirectory(Directory); - var lockPath = Path.Combine(Directory, LockFileName); + return await AcquireFileLockAsync( + Path.Combine(Directory, LockFileName), + cancellationToken, + "Warning: Report aggregation lock timed out; keeping per-suite reports and deferring aggregate refresh."); + } + private static async Task AcquireFileLockAsync( + string lockPath, + CancellationToken cancellationToken, + string timeoutWarning) + { for (var attempt = 1; attempt <= LockMaxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); @@ -195,8 +306,6 @@ internal List ReadAllSidecars() } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Contention (or a permissions hiccup) on the final attempt must still fall - // through to the graceful "skip this merge" path, never escape as a throw. if (attempt == LockMaxAttempts) { break; @@ -206,7 +315,7 @@ internal List ReadAllSidecars() } } - Console.WriteLine("Warning: Could not acquire the report aggregation lock; skipping merge for this process."); + Console.WriteLine(timeoutWarning); return null; } @@ -231,4 +340,23 @@ private static string ShortHash(string value) var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); return BitConverter.ToString(hash, 0, 4).Replace("-", "").ToLowerInvariant(); } + + private string GetExclusionMarkerPath(string assemblyName, string suiteSalt) + { + return GetSidecarPath(assemblyName, suiteSalt) + ReportDataJson.SidecarExclusionExtension; + } + + private string GetPublishingMarkerPath(string assemblyName, string suiteSalt) + { + return GetSidecarPath(assemblyName, suiteSalt) + ReportDataJson.SidecarPublishingExtension; + } + + private string GetPendingSidecarPath(string assemblyName, string suiteSalt) + => ReportDataJson.GetPendingSidecarPath(GetSidecarPath(assemblyName, suiteSalt)); + + private string GetSidecarPath(string assemblyName, string suiteSalt) + { + var fileName = $"{PathValidator.SanitizeFileName(assemblyName)}-{ShortHash(suiteSalt)}{ReportDataJson.SidecarExtension}"; + return Path.Combine(Directory, fileName); + } } diff --git a/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs b/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs index ee2ebecf3dd..2088bb690b2 100644 --- a/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs +++ b/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs @@ -25,10 +25,108 @@ internal static class ReportDataJson /// File extension shared by every sidecar so aggregators can discover them. internal const string SidecarExtension = ".tunit-report.json"; + internal const string SidecarPendingSegment = ".pending"; + internal const string SidecarExclusionExtension = ".excluded"; + internal const string SidecarPublishingExtension = ".publishing"; /// Merged HTML report filename, shared by the engine and the tool's default output. internal const string MergedReportFileName = "merged-report.html"; + internal static bool IsSidecarExcluded(string sidecarPath, ReadOnlySpan sidecarUtf8Json) + { + var canonicalPath = GetCanonicalSidecarPath(sidecarPath); + var exclusionPath = canonicalPath + SidecarExclusionExtension; + if (!File.Exists(exclusionPath)) + { + return false; + } + + var generation = GetPublicationGeneration(sidecarUtf8Json); + if (generation is null) + { + // Exclusions created for sidecars from before generation tracking remain valid. + return true; + } + + try + { + return File.ReadAllText(exclusionPath).Equals(generation, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return true; + } + } + + internal static bool IsSidecarPublicationInProgress(string sidecarPath) + { + // Pending sidecars are complete atomic files written only after a publisher's + // per-suite lock wait expires. The canonical publisher cannot mutate them. + if (IsPendingSidecar(sidecarPath)) + { + return false; + } + + var publicationLockPath = sidecarPath + SidecarPublishingExtension; + if (!File.Exists(publicationLockPath)) + { + return false; + } + + try + { + // Lock file is stable: deleting it after releasing the handle lets another + // process lock the old inode while a third process creates and locks a new one. + using (new FileStream(publicationLockPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + } + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return true; + } + } + + internal static string? GetPublicationGeneration(ReadOnlySpan sidecarUtf8Json) + { + try + { + var reader = new Utf8JsonReader(sidecarUtf8Json); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName + && reader.ValueTextEquals("publicationGeneration") + && reader.Read() + && reader.TokenType == JsonTokenType.String) + { + return reader.GetString(); + } + } + } + catch (JsonException) + { + } + + return null; + } + + internal static bool IsPendingSidecar(string sidecarPath) + => sidecarPath.EndsWith(SidecarPendingSegment + SidecarExtension, StringComparison.Ordinal); + + internal static string GetCanonicalSidecarPath(string sidecarPath) + => IsPendingSidecar(sidecarPath) + ? sidecarPath[..^(SidecarPendingSegment.Length + SidecarExtension.Length)] + SidecarExtension + : sidecarPath; + + internal static string GetPendingSidecarPath(string canonicalSidecarPath) + => canonicalSidecarPath[..^SidecarExtension.Length] + SidecarPendingSegment + SidecarExtension; + + internal static IEnumerable SelectEffectiveSidecars(IEnumerable sidecarPaths) + => sidecarPaths + .GroupBy(GetCanonicalSidecarPath, StringComparer.Ordinal) + .Select(group => group.FirstOrDefault(IsPendingSidecar) ?? group.First()); + /// /// Serializes straight to UTF-8 bytes — callers write the same payload to more than one /// file, so producing bytes once avoids a UTF-8 → string → UTF-8 round trip per copy. @@ -50,6 +148,7 @@ private static void Write(Utf8JsonWriter w, ReportData data) { w.WriteStartObject(); w.WriteNumber("schemaVersion", SchemaVersion); + w.WriteString("publicationGeneration", data.PublicationGeneration ?? Guid.NewGuid().ToString("N")); w.WriteString("assemblyName", data.AssemblyName); w.WriteString("machineName", data.MachineName); w.WriteString("timestamp", data.Timestamp); @@ -322,6 +421,7 @@ private static bool IsMalformedSidecar(Exception ex) return new ReportData { AssemblyName = GetString(root, "assemblyName") ?? "Unknown", + PublicationGeneration = GetString(root, "publicationGeneration"), MachineName = GetString(root, "machineName") ?? "", Timestamp = GetString(root, "timestamp") ?? "", TUnitVersion = GetString(root, "tunitVersion") ?? "", diff --git a/src/TUnit.Engine/Reporters/GitHubReporter.cs b/src/TUnit.Engine/Reporters/GitHubReporter.cs index cf3c21d5b0b..9348d130211 100644 --- a/src/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/src/TUnit.Engine/Reporters/GitHubReporter.cs @@ -470,6 +470,16 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) /// internal bool SuppressPerSuiteSummary { get; set; } + internal void ResetSessionState() + { + _latestUpdates.Clear(); + _terminalStateCounts.Clear(); + ArtifactUrl = null; + ShowArtifactUploadTip = false; + SuppressPerSuiteSummary = false; + _runStopwatch = Stopwatch.StartNew(); + } + /// /// Renders the aggregated block and rewrites the marked summary region. Called by /// HtmlReporter inside the aggregation lock, so the whole merge is one lock cycle. @@ -494,6 +504,14 @@ internal void WriteAggregatedSummary(IReadOnlyList suites, stri GitHubSummaryRegion.ReplaceOrAppend(_outputSummaryFilePath, content, MaxFileSizeInBytes); } + internal void ClearAggregatedSummary() + { + if (_outputSummaryFilePath is not null) + { + GitHubSummaryRegion.ReplaceOrAppend(_outputSummaryFilePath, string.Empty, MaxFileSizeInBytes); + } + } + private async Task WriteFile(string contents) { var fileInfo = new FileInfo(_outputSummaryFilePath); diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/src/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index eac0d3b23a7..95ec9473143 100644 --- a/src/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/src/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -12,6 +12,10 @@ internal sealed class ReportData [JsonPropertyName("assemblyName")] public required string AssemblyName { get; init; } + /// Atomic sidecar generation used only by aggregation cleanup. + [JsonIgnore] + public string? PublicationGeneration { get; init; } + [JsonPropertyName("machineName")] public required string MachineName { get; init; } diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 4f3f4a51846..7ff1cf76618 100644 --- a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -12,6 +12,7 @@ using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.TestHost; using TUnit.Core; +using TUnit.Core.Settings; using TUnit.Engine.Configuration; using TUnit.Engine.Constants; using TUnit.Engine.Exceptions; @@ -27,6 +28,8 @@ namespace TUnit.Engine.Reporters.Html; internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataProducer, ITestHostApplicationLifetime, ITestSessionLifetimeHandler, IFilterReceiver, IDisposable { + private const int HtmlReportEnabledUnresolved = -1; + // System.Text.Json's Utf8JsonWriter limits a single string token to int.MaxValue / 6 characters. // Truncate large outputs early so report generation never fails for test suites with excessive logging. internal const int MaxOutputLength = 1 * 1024 * 1024; // 1 MB @@ -35,7 +38,9 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP private IMessageBus? _messageBus; private string _resultsDirectory = "TestResults"; private readonly ConcurrentDictionary _updates = []; + private readonly object _htmlReportStateLock = new(); private GitHubReporter? _githubReporter; + private int _htmlReportEnabledAfterDiscovery = HtmlReportEnabledUnresolved; #if NET private ActivityCollector? _activityCollector; @@ -43,7 +48,7 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP public async Task IsEnabledAsync() { - if (IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableHtmlReporter))) + if (!IsHtmlReportEnabled()) { return false; } @@ -61,6 +66,11 @@ public async Task IsEnabledAsync() public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) { + if (!IsHtmlReportEnabledForRun()) + { + return Task.CompletedTask; + } + var testNodeUpdateMessage = (TestNodeUpdateMessage)value; // Keep only the update we'll report per test: a final-state update always wins over a // non-final one, otherwise the latest wins. The engine emits a single final update per @@ -89,10 +99,6 @@ private static bool HasFinalState(TestNodeUpdateMessage update) public Task BeforeRunAsync(CancellationToken cancellationToken) { -#if NET - _activityCollector = new ActivityCollector(); - _activityCollector.Start(); -#endif return Task.CompletedTask; } @@ -100,21 +106,62 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation) => Task.CompletedTask; // All work happens in OnTestSessionFinishingAsync. public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) - => Task.CompletedTask; + { + lock (_htmlReportStateLock) + { + _updates.Clear(); + _githubReporter?.ResetSessionState(); + Volatile.Write(ref _htmlReportEnabledAfterDiscovery, HtmlReportEnabledUnresolved); +#if NET + DisposeActivityCollection(); + // Discovery hooks may re-enable reporting for this session. Start before + // discovery so those early spans are retained, then resolve the setting + // on the first post-discovery update. + StartActivityCollection(); +#endif + } + + return Task.CompletedTask; + } public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) { try { #if NET - _activityCollector?.Stop(); + StopActivityCollection(); +#endif + + if (!IsHtmlReportEnabledForRun()) + { +#if NET + TraceRegistry.Clear(); #endif + _updates.Clear(); + var disabledOutputPath = _outputPath ?? GetDefaultOutputPath(); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable); + await DeleteSidecarsAndRefreshAggregateAsync( + GetAssemblyName(), + disabledOutputPath, + aggregator); + return; + } if (_updates.Count == 0) { #if NET TraceRegistry.Clear(); #endif + if (!IsJsonReportEnabled()) + { + var emptyOutputPath = _outputPath ?? GetDefaultOutputPath(); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable); + await DeleteSidecarsAndRefreshAggregateAsync( + GetAssemblyName(), + emptyOutputPath, + aggregator); + } + return; } @@ -163,26 +210,36 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon { Console.WriteLine($"Warning: HTML report generation failed: {ex.Message}"); } + finally + { +#if NET + DisposeActivityCollection(); +#endif + } } - private async Task TryWriteSidecarAndAggregateAsync(ReportData reportData, string htmlOutputPath, CancellationToken cancellationToken) + internal async Task TryWriteSidecarAndAggregateAsync(ReportData reportData, string htmlOutputPath, CancellationToken cancellationToken) { + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable); + + if (!IsJsonReportEnabled()) + { + await DeleteSidecarsAndRefreshAggregateAsync(reportData.AssemblyName, htmlOutputPath, aggregator); + return; + } + // Serialized once; the same bytes back both the local sidecar and the shared copy. var sidecarBytes = ReportDataJson.SerializeToBytes(reportData); - if (!IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableJsonReport))) + try { - try - { - AtomicFile.WriteAllBytes(GetSidecarPath(htmlOutputPath), sidecarBytes); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Failed to write JSON report sidecar: {ex.Message}"); - } + AtomicFile.WriteAllBytes(GetSidecarPath(htmlOutputPath), sidecarBytes); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to write JSON report sidecar: {ex.Message}"); } - var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable); if (aggregator is null) { return; @@ -190,44 +247,152 @@ private async Task TryWriteSidecarAndAggregateAsync(ReportData reportData, strin try { - aggregator.WriteSidecar(sidecarBytes, reportData.AssemblyName, suiteSalt: htmlOutputPath); + // Serialize the shared write with cleanup so cleanup cannot mistake an in-flight + // enabled generation for the stale generation it intended to remove. + using var publicationMarker = await aggregator.AcquireSidecarPublicationAsync( + reportData.AssemblyName, + htmlOutputPath, + CancellationToken.None); + if (publicationMarker is null) + { + // Publication-lock contention must not discard completed suite results. + // A separate pending slot cannot overwrite the active publisher's canonical + // generation and remains available to this or any later aggregate refresh. + aggregator.WritePendingSidecar(sidecarBytes, reportData.AssemblyName, suiteSalt: htmlOutputPath); + } + else + { + // Owning the lock makes it safe for a newer normal publication to replace + // any pending timeout result left by an earlier publisher. + aggregator.DeletePendingSidecar(reportData.AssemblyName, htmlOutputPath); + aggregator.WriteSidecar(sidecarBytes, reportData.AssemblyName, suiteSalt: htmlOutputPath); + aggregator.IncludeSidecar(reportData.AssemblyName, htmlOutputPath); + } - // Aggregation is committed for this suite: whatever happens below, the classic - // per-suite block must not be appended on top of the aggregated one. (If we - // fail past this point, a sibling that merges after us still renders this - // suite's results from the sidecar just written.) - if (_githubReporter is not null) + if (aggregator.Mode == AggregationMode.Defer && _githubReporter is not null) { _githubReporter.SuppressPerSuiteSummary = true; } - // Every finishing process regenerates the merged outputs from all sidecars - // present so far; the last one to finish leaves the complete aggregate. - using var aggregationLock = await aggregator.AcquireLockAsync(cancellationToken); + IDisposable? aggregationLock; + try + { + aggregationLock = await aggregator.AcquireLockAsync(cancellationToken); + } + catch (OperationCanceledException) + { + aggregationLock = null; + } + if (aggregationLock is null) { return; } - var suites = aggregator.ReadAllSidecars(); - if (suites.Count == 0) + using (aggregationLock) + { + publicationMarker?.Dispose(); + + RefreshAggregatedOutputs(aggregator); + if (_githubReporter is not null) + { + _githubReporter.SuppressPerSuiteSummary = true; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Report aggregation failed: {ex.Message}"); + } + } + + private async Task DeleteSidecarsAndRefreshAggregateAsync( + string assemblyName, + string htmlOutputPath, + ReportAggregator? aggregator) + { + TryDeleteReportFile(() => File.Delete(GetSidecarPath(htmlOutputPath))); + + if (aggregator is null) + { + return; + } + + if (!aggregator.HasSidecarState(assemblyName, htmlOutputPath)) + { + return; + } + + try + { + var expectedGeneration = aggregator.ReadEffectiveSidecarGeneration(assemblyName, htmlOutputPath); + using var publicationMarker = aggregator.TryAcquireSidecarPublication(assemblyName, htmlOutputPath); + if (publicationMarker is null) + { + // An enabled publication may already have installed its generation while + // holding this lock. Cleanup must not wait, acquire next, and hide it. + return; + } + + // Exclude only the generation observed before the lock attempt. A publisher that + // won the per-suite lock in the meantime installed a newer generation and survives. + aggregator.ExcludeSidecarIfGenerationMatches(assemblyName, htmlOutputPath, expectedGeneration); + + // The exclusion is durable and generation-scoped, so do not hold the per-suite + // lock during the bounded aggregate-lock wait. A staged enabled publication + // must be able to acquire it and clear the exclusion before either wait expires. + publicationMarker.Dispose(); + + // Cleanup must survive session cancellation or stale enabled-run sidecars can + // re-enter a sibling's aggregate. Lock acquisition remains time-bounded. + using var aggregationLock = await aggregator.AcquireLockAsync(CancellationToken.None); + if (aggregationLock is null) { return; } - aggregator.WriteMergedHtml(suites); - Console.WriteLine($"Merged HTML test report ({suites.Count} {(suites.Count == 1 ? "suite" : "suites")} so far) written to: {aggregator.MergedReportPath}"); + RefreshAggregatedOutputs(aggregator); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Console.WriteLine($"Warning: Report aggregation cleanup failed: {ex.Message}"); + } + } - // The step summary is rewritten in the same lock cycle: one env parse, one - // lock acquisition and one sidecar scan per process for both merged outputs. + private void RefreshAggregatedOutputs(ReportAggregator aggregator) + { + var suites = aggregator.ReadAllSidecars(); + if (suites.Count == 0) + { + TryDeleteReportFile(() => File.Delete(aggregator.MergedReportPath)); if (aggregator.Mode == AggregationMode.Cooperative) { - _githubReporter?.WriteAggregatedSummary(suites, aggregator.MergedReportPath); + _githubReporter?.ClearAggregatedSummary(); } + + return; + } + + aggregator.WriteMergedHtml(suites); + Console.WriteLine($"Merged HTML test report ({suites.Count} {(suites.Count == 1 ? "suite" : "suites")} so far) written to: {aggregator.MergedReportPath}"); + + // The step summary is rewritten in the same lock cycle: one env parse, one + // lock acquisition and one sidecar scan per process for both merged outputs. + if (aggregator.Mode == AggregationMode.Cooperative) + { + _githubReporter?.WriteAggregatedSummary(suites, aggregator.MergedReportPath); + } + } + + private static void TryDeleteReportFile(Action deleteFile) + { + try + { + deleteFile(); } catch (Exception ex) { - Console.WriteLine($"Warning: Report aggregation failed: {ex.Message}"); + Console.WriteLine($"Warning: Failed to remove disabled report file: {ex.Message}"); } } @@ -238,6 +403,53 @@ private static bool IsTruthyEnv(string? value) value.Equals("1", StringComparison.Ordinal) || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); + internal static bool IsHtmlReportEnabled() + => !IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableHtmlReporter)) + && TUnitSettings.Default.Reporting.HtmlReportEnabled; + + internal static bool IsJsonReportEnabled() + => !IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableJsonReport)) + && TUnitSettings.Default.Reporting.JsonReportEnabled; + + internal static bool IsArtifactUploadEnabled() + => !IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableArtifactUpload)) + && TUnitSettings.Default.Reporting.ArtifactUploadEnabled; + + internal bool IsHtmlReportEnabledForRun() + { + var resolved = Volatile.Read(ref _htmlReportEnabledAfterDiscovery); + if (resolved != HtmlReportEnabledUnresolved) + { + return resolved == 1; + } + + lock (_htmlReportStateLock) + { + resolved = Volatile.Read(ref _htmlReportEnabledAfterDiscovery); + if (resolved != HtmlReportEnabledUnresolved) + { + return resolved == 1; + } + + var enabled = IsHtmlReportEnabled(); + +#if NET + if (enabled) + { + StartActivityCollection(); + } + else + { + DisposeActivityCollection(); + TraceRegistry.Clear(); + } +#endif + + Volatile.Write(ref _htmlReportEnabledAfterDiscovery, enabled ? 1 : 0); + return enabled; + } + } + // Default HTML report is "{name}-{os}-{tfm}-report.html"; the sidecar drops the // "-report" stem so the default pair reads "{name}-{os}-{tfm}.tunit-report.json". internal static string GetSidecarPath(string htmlOutputPath) @@ -253,7 +465,7 @@ internal static string GetSidecarPath(string htmlOutputPath) internal async Task PublishArtifactAsync(string outputPath, SessionUid sessionUid, CancellationToken cancellationToken) { - if (_messageBus is null) + if (_messageBus is null || !IsArtifactUploadEnabled()) { return; } @@ -270,10 +482,34 @@ internal async Task PublishArtifactAsync(string outputPath, SessionUid sessionUi public void Dispose() { #if NET - _activityCollector?.Dispose(); + DisposeActivityCollection(); #endif } +#if NET + internal bool HasActivityCollector => _activityCollector is not null; + + private void StartActivityCollection() + { + if (_activityCollector is not null) + { + return; + } + + _activityCollector = new ActivityCollector(); + _activityCollector.Start(); + } + + internal void StopActivityCollection() + => _activityCollector?.Stop(); + + private void DisposeActivityCollection() + { + _activityCollector?.Dispose(); + _activityCollector = null; + } +#endif + public string? Filter { get; set; } internal void SetOutputPath(string path) @@ -307,7 +543,7 @@ internal void SetResultsDirectory(string path) internal ReportData BuildReportData() { - var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + var assemblyName = GetAssemblyName(); var tunitVersion = typeof(HtmlReporter).Assembly.GetName().Version?.ToString() ?? "unknown"; // Get the last update with a final state for each test @@ -747,13 +983,16 @@ private static (string Status, ReportExceptionData? Exception, string? SkipReaso private string GetDefaultOutputPath() { - var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + var assemblyName = GetAssemblyName(); var sanitizedName = PathValidator.SanitizeFileName(assemblyName); var os = GetShortOsName(); var tfm = GetShortFrameworkName(); return Path.GetFullPath(Path.Combine(_resultsDirectory, $"{sanitizedName}-{os}-{tfm}-report.html")); } + private static string GetAssemblyName() + => Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + private static string GetShortOsName() { #if NET @@ -857,7 +1096,7 @@ private static bool IsFileLocked(IOException exception) return null; } - if (IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableArtifactUpload))) + if (!IsArtifactUploadEnabled()) { return null; } diff --git a/src/TUnit.Reporting.Tool/Program.cs b/src/TUnit.Reporting.Tool/Program.cs index b05183e5dc0..82caaeb626e 100644 --- a/src/TUnit.Reporting.Tool/Program.cs +++ b/src/TUnit.Reporting.Tool/Program.cs @@ -153,8 +153,17 @@ private static (List Suites, int Skipped) LoadSidecars(string direct // abort the walk — SearchOption.AllDirectories would throw mid-enumeration, before // the per-file guard below ever ran. var enumeration = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; - foreach (var file in Directory.EnumerateFiles(directory, "*" + ReportDataJson.SidecarExtension, enumeration)) + var effectiveSidecars = ReportDataJson.SelectEffectiveSidecars( + Directory.EnumerateFiles(directory, "*" + ReportDataJson.SidecarExtension, enumeration)); + foreach (var file in effectiveSidecars) { + // This command is the designated final merge, so no publisher should still + // be active. Skip any suite whose stable publication lock is still held. + if (ReportDataJson.IsSidecarPublicationInProgress(file)) + { + continue; + } + byte[] bytes; try { @@ -168,6 +177,11 @@ private static (List Suites, int Skipped) LoadSidecars(string direct continue; } + if (ReportDataJson.IsSidecarExcluded(file, bytes)) + { + continue; + } + if (!seenDigests.Add(Convert.ToBase64String(sha.ComputeHash(bytes)))) { continue; diff --git a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs index 38295421c26..d8c31e7d26b 100644 --- a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs +++ b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs @@ -3,6 +3,7 @@ using Shouldly; using TUnit.Engine.Exceptions; using TUnit.Engine.Reporters; +using TUnit.Engine.Reporters.Aggregation; namespace TUnit.Engine.Tests; @@ -114,6 +115,42 @@ public async Task IsEnabledAsync_Should_Return_False_When_GITHUB_ACTIONS_Is_Not_ result.ShouldBeFalse(); } + [Test] + public async Task ClearAggregatedSummary_Removes_Stale_Content() + { + var (reporter, outputFile) = await SetupReporter(); + GitHubSummaryRegion.ReplaceOrAppend(outputFile, "stale aggregate"); + + reporter.ClearAggregatedSummary(); + + File.ReadAllText(outputFile).ShouldNotContain("stale aggregate"); + } + + [Test] + public async Task ResetSessionState_Clears_Test_And_Presentation_State() + { + var (reporter, outputFile) = await SetupReporter(); + await FeedTestMessages(reporter, + CreatePassedTestMessage("retry", "CurrentTest", "Tests"), + CreatePassedTestMessage("retry", "CurrentTest", "Tests"), + CreatePassedTestMessage("stale", "StaleTest", "Tests")); + reporter.ArtifactUrl = "https://example.com/old-artifact"; + reporter.ShowArtifactUploadTip = true; + reporter.SuppressPerSuiteSummary = true; + + reporter.ResetSessionState(); + await FeedTestMessages(reporter, CreatePassedTestMessage("retry", "CurrentTest", "Tests")); + await reporter.AfterRunAsync(0, CancellationToken.None); + + var output = await File.ReadAllTextAsync(outputFile); + output.ShouldContain("**1 tests**"); + output.ShouldNotContain("StaleTest"); + output.ShouldNotContain("flaky"); + reporter.ArtifactUrl.ShouldBeNull(); + reporter.ShowArtifactUploadTip.ShouldBeFalse(); + reporter.SuppressPerSuiteSummary.ShouldBeFalse(); + } + [Test] public async Task AfterRunAsync_Groups_Failures_By_Exception_Type() { diff --git a/tests/TUnit.Engine.Tests/HtmlReporterConfigurationTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterConfigurationTests.cs new file mode 100644 index 00000000000..3b4e53c6d28 --- /dev/null +++ b/tests/TUnit.Engine.Tests/HtmlReporterConfigurationTests.cs @@ -0,0 +1,755 @@ +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; +using Shouldly; +using TUnit.Core.Settings; +using TUnit.Engine.Reporters; +using TUnit.Engine.Reporters.Aggregation; +using TUnit.Engine.Reporters.Html; + +namespace TUnit.Engine.Tests; + +[NotInParallel] +public class HtmlReporterConfigurationTests +{ + private bool _htmlReportEnabled; + private bool _jsonReportEnabled; + private bool _artifactUploadEnabled; + private string? _disableHtmlReporter; + private string? _disableJsonReport; + private string? _disableArtifactUpload; + private string? _aggregateReports; + private string? _aggregateDirectory; + + [Before(HookType.Test)] + public void SnapshotConfiguration() + { + _htmlReportEnabled = TUnitSettings.Default.Reporting.HtmlReportEnabled; + _jsonReportEnabled = TUnitSettings.Default.Reporting.JsonReportEnabled; + _artifactUploadEnabled = TUnitSettings.Default.Reporting.ArtifactUploadEnabled; + _disableHtmlReporter = Environment.GetEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER"); + _disableJsonReport = Environment.GetEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT"); + _disableArtifactUpload = Environment.GetEnvironmentVariable("TUNIT_DISABLE_ARTIFACT_UPLOAD"); + _aggregateReports = Environment.GetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS"); + _aggregateDirectory = Environment.GetEnvironmentVariable("TUNIT_AGGREGATE_DIR"); + + Environment.SetEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER", null); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT", null); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_ARTIFACT_UPLOAD", null); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", null); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", null); + } + + [After(HookType.Test)] + public void RestoreConfiguration() + { + TUnitSettings.Default.Reporting.HtmlReportEnabled = _htmlReportEnabled; + TUnitSettings.Default.Reporting.JsonReportEnabled = _jsonReportEnabled; + TUnitSettings.Default.Reporting.ArtifactUploadEnabled = _artifactUploadEnabled; + Environment.SetEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER", _disableHtmlReporter); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT", _disableJsonReport); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_ARTIFACT_UPLOAD", _disableArtifactUpload); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", _aggregateReports); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", _aggregateDirectory); + } + + [Test] + public async Task Programmatic_Settings_Can_Disable_Html_Reporting_Features() + { + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + TUnitSettings.Default.Reporting.ArtifactUploadEnabled = false; + + await Assert.That(HtmlReporter.IsHtmlReportEnabled()).IsFalse(); + await Assert.That(HtmlReporter.IsJsonReportEnabled()).IsFalse(); + await Assert.That(HtmlReporter.IsArtifactUploadEnabled()).IsFalse(); + } + + [Test] + public async Task Disable_Environment_Variables_Take_Precedence() + { + Environment.SetEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER", "true"); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT", "1"); + Environment.SetEnvironmentVariable("TUNIT_DISABLE_ARTIFACT_UPLOAD", "yes"); + + await Assert.That(HtmlReporter.IsHtmlReportEnabled()).IsFalse(); + await Assert.That(HtmlReporter.IsJsonReportEnabled()).IsFalse(); + await Assert.That(HtmlReporter.IsArtifactUploadEnabled()).IsFalse(); + } + + [Test] + public async Task Disabled_Html_Report_Stops_Activity_Collection_After_Discovery(CancellationToken cancellationToken) + { + using var reporter = new HtmlReporter(new MockExtension()); + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + reporter.HasActivityCollector.ShouldBeTrue(); + + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + await reporter.ConsumeAsync(reporter, null!, cancellationToken); + + reporter.HasActivityCollector.ShouldBeFalse(); + } + + [Test] + public async Task Html_Report_Setting_Is_Resolved_Per_Session(CancellationToken cancellationToken) + { + using var reporter = new HtmlReporter(new MockExtension()); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + reporter.IsHtmlReportEnabledForRun().ShouldBeFalse(); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + TUnitSettings.Default.Reporting.HtmlReportEnabled = true; + reporter.IsHtmlReportEnabledForRun().ShouldBeTrue(); + } + + [Test] + public async Task Activity_Collection_Starts_Before_Discovery_Reenables_Reporting(CancellationToken cancellationToken) + { + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + using var reporter = new HtmlReporter(new MockExtension()); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + reporter.HasActivityCollector.ShouldBeTrue(); + + var activity = TUnitActivitySource.StartLifecycleActivity(TUnitActivitySource.SpanTestSession); + TUnitSettings.Default.Reporting.HtmlReportEnabled = true; + reporter.IsHtmlReportEnabledForRun().ShouldBeTrue(); + TUnitActivitySource.StopActivity(activity); + + reporter.StopActivityCollection(); + var spans = reporter.BuildReportData().Spans; + spans.ShouldNotBeNull(); + spans.ShouldContain(span => span.SpanType == TUnitActivitySource.SpanTestSession); + } + + [Test] + public async Task Activity_Collection_Is_Recreated_Between_Sessions(CancellationToken cancellationToken) + { + TUnitSettings.Default.Reporting.HtmlReportEnabled = true; + using var reporter = new HtmlReporter(new MockExtension()); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + reporter.HasActivityCollector.ShouldBeTrue(); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionFinishingAsync(null!); + reporter.HasActivityCollector.ShouldBeFalse(); + + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + reporter.HasActivityCollector.ShouldBeTrue(); + + var activity = TUnitActivitySource.StartLifecycleActivity(TUnitActivitySource.SpanTestSession); + TUnitActivitySource.StopActivity(activity); + reporter.StopActivityCollection(); + var spans = reporter.BuildReportData().Spans; + spans.ShouldNotBeNull(); + spans.ShouldContain(span => span.SpanType == TUnitActivitySource.SpanTestSession); + } + + [Test] + public async Task Test_Updates_Are_Cleared_Between_Sessions(CancellationToken cancellationToken) + { + using var reporter = new HtmlReporter(new MockExtension()); + + await reporter.OnTestSessionStartingAsync(null!); + await reporter.ConsumeAsync(reporter, CreatePassedUpdate("first"), cancellationToken); + reporter.BuildReportData().Groups.SelectMany(x => x.Tests).Single().Id.ShouldBe("first"); + + await reporter.OnTestSessionStartingAsync(null!); + await reporter.ConsumeAsync(reporter, CreatePassedUpdate("second"), cancellationToken); + + reporter.BuildReportData().Groups.SelectMany(x => x.Tests).Single().Id.ShouldBe("second"); + } + + [Test] + public async Task GitHub_Report_State_Is_Reset_Between_Sessions(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", Path.Combine(tempDirectory, "aggregate")); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var githubReporter = new GitHubReporter(new MockExtension()); + reporter.SetGitHubReporter(githubReporter); + + await reporter.TryWriteSidecarAndAggregateAsync( + CreateReportData(), + Path.Combine(tempDirectory, "suite-report.html"), + cancellationToken); + githubReporter.SuppressPerSuiteSummary.ShouldBeTrue(); + githubReporter.ArtifactUrl = "https://example.com/old-artifact"; + githubReporter.ShowArtifactUploadTip = true; + + await reporter.OnTestSessionStartingAsync(null!); + + githubReporter.SuppressPerSuiteSummary.ShouldBeFalse(); + githubReporter.ArtifactUrl.ShouldBeNull(); + githubReporter.ShowArtifactUploadTip.ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Disabled_Artifact_Upload_Does_Not_Publish_Session_File_Artifact(CancellationToken cancellationToken) + { + TUnitSettings.Default.Reporting.ArtifactUploadEnabled = false; + var reporter = new HtmlReporter(new MockExtension()); + var messageBus = new CapturingMessageBus(); + reporter.SetMessageBus(messageBus); + + await reporter.PublishArtifactAsync("report.html", new SessionUid("session"), cancellationToken); + + messageBus.Published.ShouldBeEmpty(); + } + + [Test] + public async Task Disabled_Json_Report_Removes_Stale_Aggregation_Outputs(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var removedHtmlPath = Path.Combine(tempDirectory, "removed-report.html"); + var remainingHtmlPath = Path.Combine(tempDirectory, "remaining-report.html"); + var mergedReportPath = Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + + TUnitSettings.Default.Reporting.JsonReportEnabled = true; + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData("RemovedSuiteMarker"), removedHtmlPath, cancellationToken); + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData("RemainingSuiteMarker"), remainingHtmlPath, cancellationToken); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(2); + + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData("RemovedSuiteMarker"), removedHtmlPath, cancellationToken); + + File.Exists(HtmlReporter.GetSidecarPath(removedHtmlPath)).ShouldBeFalse(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(2); + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("RemainingSuiteMarker"); + var mergedReport = File.ReadAllText(mergedReportPath); + mergedReport.ShouldNotContain("RemovedSuiteMarker"); + mergedReport.ShouldContain("RemainingSuiteMarker"); + + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData("RemainingSuiteMarker"), remainingHtmlPath, cancellationToken); + + File.Exists(HtmlReporter.GetSidecarPath(remainingHtmlPath)).ShouldBeFalse(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(2); + aggregator.ReadAllSidecars().ShouldBeEmpty(); + File.Exists(mergedReportPath).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Cancelled_Session_Still_Removes_Disabled_Report_Sidecars(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "cancelled-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + + TUnitSettings.Default.Reporting.JsonReportEnabled = true; + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(), htmlPath, cancellationToken); + + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cancelled.Cancel(); + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(), htmlPath, cancelled.Token); + + File.Exists(HtmlReporter.GetSidecarPath(htmlPath)).ShouldBeFalse(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(1); + aggregator.ReadAllSidecars().ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Disabled_Marker_Excludes_Stale_Shared_Sidecar(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", tempDirectory); + + try + { + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + var reportData = CreateReportData("DisabledSuiteMarker"); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(reportData), reportData.AssemblyName, "suite"); + var staleGeneration = aggregator.ReadEffectiveSidecarGeneration(reportData.AssemblyName, "suite"); + + var replacement = CreateReportData(reportData.AssemblyName, "replacement-machine"); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(replacement), replacement.AssemblyName, "suite"); + aggregator.ExcludeSidecarIfGenerationMatches(reportData.AssemblyName, "suite", staleGeneration); + Directory.GetFiles(tempDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").ShouldBeEmpty(); + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("replacement-machine"); + + aggregator.ExcludeSidecar(reportData.AssemblyName, "suite"); + aggregator.ReadAllSidecars().ShouldBeEmpty(); + + var latest = CreateReportData(reportData.AssemblyName, "latest-machine"); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(latest), latest.AssemblyName, "suite"); + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("latest-machine"); + + aggregator.IncludeSidecar(reportData.AssemblyName, "suite"); + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("latest-machine"); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Disabled_Html_Report_Removes_Stale_Aggregation_Outputs(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var disabledHtmlPath = Path.Combine(tempDirectory, "disabled-report.html"); + var remainingHtmlPath = Path.Combine(tempDirectory, "remaining-report.html"); + var mergedReportPath = Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + reporter.SetOutputPath(disabledHtmlPath); + var disabledAssemblyName = reporter.BuildReportData().AssemblyName; + + TUnitSettings.Default.Reporting.JsonReportEnabled = true; + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(disabledAssemblyName), disabledHtmlPath, cancellationToken); + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData("RemainingSuiteMarker"), remainingHtmlPath, cancellationToken); + + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + await reporter.OnTestSessionFinishingAsync(null!); + + File.Exists(HtmlReporter.GetSidecarPath(disabledHtmlPath)).ShouldBeFalse(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(2); + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("RemainingSuiteMarker"); + var mergedReport = File.ReadAllText(mergedReportPath); + mergedReport.ShouldNotContain(disabledAssemblyName); + mergedReport.ShouldContain("RemainingSuiteMarker"); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Shared_Sidecar_Stays_Hidden_Until_Lock_Wait_Completes(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable); + var aggregationLock = await aggregator!.AcquireLockAsync(cancellationToken); + aggregationLock.ShouldNotBeNull(); + + Task writeTask; + using (aggregationLock!) + { + writeTask = reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(), htmlPath, cancellationToken); + + File.Exists(HtmlReporter.GetSidecarPath(htmlPath)).ShouldBeTrue(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(1); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarPublishingExtension}").Length.ShouldBe(1); + aggregator.ReadAllSidecars().ShouldBeEmpty(); + } + + await writeTask; + + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(1); + File.ReadAllText(Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName)).ShouldContain("Tests"); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Empty_Session_With_Disabled_Json_Removes_Stale_Sidecars(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + reporter.SetOutputPath(htmlPath); + var assemblyName = reporter.BuildReportData().AssemblyName; + + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(assemblyName), htmlPath, cancellationToken); + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + + await reporter.OnTestSessionFinishingAsync(null!); + + File.Exists(HtmlReporter.GetSidecarPath(htmlPath)).ShouldBeFalse(); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(1); + aggregator.ReadAllSidecars().ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Publication_Lock_File_Is_Stable_And_Reused(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", tempDirectory); + + try + { + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + var reportData = CreateReportData(); + using var publicationLock = aggregator.BeginSidecarPublication(reportData.AssemblyName, "suite"); + var sidecarPath = aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(reportData), reportData.AssemblyName, "suite"); + + aggregator.ReadAllSidecars().ShouldBeEmpty(); + publicationLock.Dispose(); + + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("Tests"); + File.Exists(sidecarPath + ReportDataJson.SidecarPublishingExtension).ShouldBeTrue(); + + using (aggregator.BeginSidecarPublication(reportData.AssemblyName, "suite")) + { + aggregator.ReadAllSidecars().ShouldBeEmpty(); + } + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Cancelled_Lock_Wait_Keeps_Per_Suite_Summary(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + using var reporter = new HtmlReporter(new MockExtension()); + var githubReporter = new GitHubReporter(new MockExtension()); + reporter.SetGitHubReporter(githubReporter); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + using var aggregationLock = await aggregator.AcquireLockAsync(cancellationToken); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + aggregator.ExcludeSidecar("Tests", htmlPath); + + await reporter.TryWriteSidecarAndAggregateAsync( + CreateReportData(), + htmlPath, + cancelled.Token); + + githubReporter.SuppressPerSuiteSummary.ShouldBeFalse(); + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("Tests"); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").ShouldBeEmpty(); + File.Exists(Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName)).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Cancelled_Defer_Lock_Wait_Suppresses_Per_Suite_Summary(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "defer"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + using var reporter = new HtmlReporter(new MockExtension()); + var githubReporter = new GitHubReporter(new MockExtension()); + reporter.SetGitHubReporter(githubReporter); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + using var aggregationLock = await aggregator.AcquireLockAsync(cancellationToken); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + + await reporter.TryWriteSidecarAndAggregateAsync( + CreateReportData(), + htmlPath, + cancelled.Token); + + githubReporter.SuppressPerSuiteSummary.ShouldBeTrue(); + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("Tests"); + File.Exists(Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName)).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + [Timeout(30_000)] + public async Task Disabled_Cleanup_Does_Not_Exclude_Active_Publication(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + var reportData = CreateReportData(); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(reportData), reportData.AssemblyName, htmlPath); + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + + using (aggregator.BeginSidecarPublication(reportData.AssemblyName, htmlPath)) + { + var replacement = CreateReportData(reportData.AssemblyName, "active-publisher"); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(replacement), replacement.AssemblyName, htmlPath); + await reporter.TryWriteSidecarAndAggregateAsync(reportData, htmlPath, cancellationToken); + } + + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("active-publisher"); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + [Timeout(30_000)] + public async Task Enabled_Publication_Contention_Preserves_Shared_Sidecar(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + var reportData = CreateReportData("ContendedSuiteMarker", "timeout-publisher"); + + using (aggregator.BeginSidecarPublication(reportData.AssemblyName, htmlPath)) + { + var activeReport = CreateReportData(reportData.AssemblyName, "active-publisher"); + aggregator.WriteSidecar(ReportDataJson.SerializeToBytes(activeReport), activeReport.AssemblyName, htmlPath); + await reporter.TryWriteSidecarAndAggregateAsync(reportData, htmlPath, cancellationToken); + + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(2); + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("timeout-publisher"); + File.Exists(Path.Combine(aggregationDirectory, ReportDataJson.MergedReportFileName)).ShouldBeTrue(); + } + + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("timeout-publisher"); + + var newerReport = CreateReportData(reportData.AssemblyName, "newer-publisher"); + await reporter.TryWriteSidecarAndAggregateAsync(newerReport, htmlPath, cancellationToken); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExtension}").Length.ShouldBe(1); + aggregator.ReadAllSidecars().Single().MachineName.ShouldBe("newer-publisher"); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + [Timeout(30_000)] + public async Task Enabled_Publication_Supersedes_In_Flight_Disabled_Cleanup(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + Directory.CreateDirectory(tempDirectory); + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + var oldReport = CreateReportData(machineName: "old-machine"); + await reporter.TryWriteSidecarAndAggregateAsync(oldReport, htmlPath, cancellationToken); + + var aggregationLock = await aggregator.AcquireLockAsync(cancellationToken); + aggregationLock.ShouldNotBeNull(); + Task cleanupTask; + Task publicationTask; + using (aggregationLock!) + { + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + cleanupTask = reporter.TryWriteSidecarAndAggregateAsync(oldReport, htmlPath, cancellationToken); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").Length.ShouldBe(1); + + TUnitSettings.Default.Reporting.JsonReportEnabled = true; + var newReport = CreateReportData(machineName: "new-machine"); + publicationTask = reporter.TryWriteSidecarAndAggregateAsync(newReport, htmlPath, cancellationToken); + } + + await Task.WhenAll(cleanupTask, publicationTask); + + var publishedReport = aggregator.ReadAllSidecars().Single(); + publishedReport.MachineName.ShouldBe("new-machine"); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [Test] + public async Task Enabled_Publication_Clears_Stale_Exclusion(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-test-{Guid.NewGuid():N}"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + var htmlPath = Path.Combine(tempDirectory, "suite-report.html"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true"); + Environment.SetEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory); + + try + { + using var reporter = new HtmlReporter(new MockExtension()); + var aggregator = ReportAggregator.TryCreateFromEnvironment(Environment.GetEnvironmentVariable)!; + aggregator.ExcludeSidecar("Tests", htmlPath); + + await reporter.TryWriteSidecarAndAggregateAsync(CreateReportData(), htmlPath, cancellationToken); + + aggregator.ReadAllSidecars().Single().AssemblyName.ShouldBe("Tests"); + Directory.GetFiles(aggregationDirectory, $"*{ReportDataJson.SidecarExclusionExtension}").ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + private static ReportData CreateReportData(string assemblyName = "Tests", string machineName = "machine") => new() + { + AssemblyName = assemblyName, + MachineName = machineName, + Timestamp = DateTimeOffset.UtcNow.ToString("O"), + TUnitVersion = "1.0.0", + OperatingSystem = "test", + RuntimeVersion = "test", + Summary = new ReportSummary(), + Groups = [], + }; + + private static TestNodeUpdateMessage CreatePassedUpdate(string id) => new( + new SessionUid("session"), + new TestNode + { + Uid = new TestNodeUid(id), + DisplayName = id, + Properties = new PropertyBag(PassedTestNodeStateProperty.CachedInstance), + }); +} diff --git a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs index 5d7d646d22e..05123ed29cc 100644 --- a/tests/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/tests/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -30,6 +30,23 @@ public void HtmlReporter_DataTypesProduced_Contains_SessionFileArtifact() producer.DataTypesProduced.ShouldContain(typeof(SessionFileArtifact)); } + [Test] + public async Task Stopping_Activity_Collection_Preserves_Spans_For_Report_Data(CancellationToken cancellationToken) + { + using var reporter = new HtmlReporter(new MockExtension()); + cancellationToken.ThrowIfCancellationRequested(); + await reporter.OnTestSessionStartingAsync(null!); + + var activity = TUnitActivitySource.StartLifecycleActivity(TUnitActivitySource.SpanTestSession); + TUnitActivitySource.StopActivity(activity); + reporter.StopActivityCollection(); + + var reportData = reporter.BuildReportData(); + + reportData.Spans.ShouldNotBeNull(); + reportData.Spans.ShouldContain(span => span.SpanType == TUnitActivitySource.SpanTestSession); + } + [Test] public async Task PublishArtifactAsync_Publishes_SessionFileArtifact_When_SessionContext_Set_And_File_Exists() { diff --git a/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs b/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs new file mode 100644 index 00000000000..1ca19d4ecfe --- /dev/null +++ b/tests/TUnit.Engine.Tests/ReportingSettingsTests.cs @@ -0,0 +1,45 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class ReportingSettingsTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Discovery_Hook_Can_Disable_Reporting(CancellationToken cancellationToken) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"tunit-report-settings-{Guid.NewGuid():N}"); + var reportPath = Path.Combine(tempDirectory, "report.html"); + var aggregationDirectory = Path.Combine(tempDirectory, "aggregate"); + + try + { + var options = new RunOptions() + .WithArgument("--report-html-filename") + .WithArgument(reportPath) + .WithEnvironmentVariable("TUNIT_DISABLE_HTML_REPORTER", "false") + .WithEnvironmentVariable("TUNIT_DISABLE_JSON_REPORT", "false") + .WithEnvironmentVariable("TUNIT_DISABLE_ARTIFACT_UPLOAD", "false") + .WithEnvironmentVariable("TUNIT_AGGREGATE_REPORTS", "true") + .WithEnvironmentVariable("TUNIT_AGGREGATE_DIR", aggregationDirectory) + .WithEnvironmentVariable("TUNIT_TEST_DISABLE_REPORTING_FROM_DISCOVERY_HOOK", "true") + .WithGracefulCancellationToken(cancellationToken); + + await RunTestsWithFilter( + "/*/*/ReportingSettingsTests/*", + [ + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + _ => File.Exists(reportPath).ShouldBeFalse(), + _ => Directory.Exists(aggregationDirectory).ShouldBeFalse(), + ], + options); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } +} diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 863682c5d02..2c000ddceac 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -3046,11 +3046,18 @@ namespace .Settings { public int? MaximumParallelTests { get; set; } } + public sealed class ReportingSettings + { + public bool ArtifactUploadEnabled { get; set; } + public bool HtmlReportEnabled { get; set; } + public bool JsonReportEnabled { get; set; } + } public sealed class TUnitSettings { public . Display { get; } public . Execution { get; } public . Parallelism { get; } + public . Reporting { get; } public . Timeouts { get; } } public sealed class TimeoutSettings diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index df987e2c496..7829d687fc6 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -3046,11 +3046,18 @@ namespace .Settings { public int? MaximumParallelTests { get; set; } } + public sealed class ReportingSettings + { + public bool ArtifactUploadEnabled { get; set; } + public bool HtmlReportEnabled { get; set; } + public bool JsonReportEnabled { get; set; } + } public sealed class TUnitSettings { public . Display { get; } public . Execution { get; } public . Parallelism { get; } + public . Reporting { get; } public . Timeouts { get; } } public sealed class TimeoutSettings diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 8657b96496c..11d504d7622 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -3046,11 +3046,18 @@ namespace .Settings { public int? MaximumParallelTests { get; set; } } + public sealed class ReportingSettings + { + public bool ArtifactUploadEnabled { get; set; } + public bool HtmlReportEnabled { get; set; } + public bool JsonReportEnabled { get; set; } + } public sealed class TUnitSettings { public . Display { get; } public . Execution { get; } public . Parallelism { get; } + public . Reporting { get; } public . Timeouts { get; } } public sealed class TimeoutSettings diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index bc6b0a77b30..fb14bdc8ccc 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2968,11 +2968,18 @@ namespace .Settings { public int? MaximumParallelTests { get; set; } } + public sealed class ReportingSettings + { + public bool ArtifactUploadEnabled { get; set; } + public bool HtmlReportEnabled { get; set; } + public bool JsonReportEnabled { get; set; } + } public sealed class TUnitSettings { public . Display { get; } public . Execution { get; } public . Parallelism { get; } + public . Reporting { get; } public . Timeouts { get; } } public sealed class TimeoutSettings diff --git a/tests/TUnit.TestProject/ReportingSettingsTests.cs b/tests/TUnit.TestProject/ReportingSettingsTests.cs new file mode 100644 index 00000000000..5bba1a327da --- /dev/null +++ b/tests/TUnit.TestProject/ReportingSettingsTests.cs @@ -0,0 +1,28 @@ +namespace TUnit.TestProject; + +public static class ReportingSettingsHooks +{ + internal const string DisableReportingEnvironmentVariable = "TUNIT_TEST_DISABLE_REPORTING_FROM_DISCOVERY_HOOK"; + + [Before(TestDiscovery)] + public static void ConfigureReporting(BeforeTestDiscoveryContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Environment.GetEnvironmentVariable(DisableReportingEnvironmentVariable) is not "true") + { + return; + } + + context.Settings.Reporting.HtmlReportEnabled = false; + context.Settings.Reporting.JsonReportEnabled = false; + context.Settings.Reporting.ArtifactUploadEnabled = false; + } +} + +public class ReportingSettingsTests +{ + [Test] + public void Test(CancellationToken cancellationToken) + => cancellationToken.ThrowIfCancellationRequested(); +} diff --git a/tests/TUnit.UnitTests/ReportAggregationTests.cs b/tests/TUnit.UnitTests/ReportAggregationTests.cs index c739508d64c..dd5a38bf963 100644 --- a/tests/TUnit.UnitTests/ReportAggregationTests.cs +++ b/tests/TUnit.UnitTests/ReportAggregationTests.cs @@ -17,6 +17,7 @@ public async Task Serialize_Then_Deserialize_RoundTrips_AllFields() await Assert.That(restored).IsNotNull(); await Assert.That(restored!.AssemblyName).IsEqualTo("My.Tests"); + await Assert.That(restored.PublicationGeneration).IsNotNull(); await Assert.That(restored.MachineName).IsEqualTo(original.MachineName); await Assert.That(restored.Timestamp).IsEqualTo(original.Timestamp); await Assert.That(restored.TUnitVersion).IsEqualTo(original.TUnitVersion); diff --git a/tests/TUnit.UnitTests/TUnitSettingsTests.cs b/tests/TUnit.UnitTests/TUnitSettingsTests.cs index 287fdbd63b5..054ba8ff403 100644 --- a/tests/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/tests/TUnit.UnitTests/TUnitSettingsTests.cs @@ -18,6 +18,9 @@ public class TUnitSettingsTests private int? _savedMaximumParallelTests; private bool _savedDetailedStackTrace; private bool _savedFailFast; + private bool _savedHtmlReportEnabled; + private bool _savedJsonReportEnabled; + private bool _savedArtifactUploadEnabled; [Before(HookType.Test)] public void SnapshotSettings() @@ -29,6 +32,9 @@ public void SnapshotSettings() _savedMaximumParallelTests = TUnitSettings.Default.Parallelism.MaximumParallelTests; _savedDetailedStackTrace = TUnitSettings.Default.Display.DetailedStackTrace; _savedFailFast = TUnitSettings.Default.Execution.FailFast; + _savedHtmlReportEnabled = TUnitSettings.Default.Reporting.HtmlReportEnabled; + _savedJsonReportEnabled = TUnitSettings.Default.Reporting.JsonReportEnabled; + _savedArtifactUploadEnabled = TUnitSettings.Default.Reporting.ArtifactUploadEnabled; } [After(HookType.Test)] @@ -41,6 +47,9 @@ public void RestoreSettings() TUnitSettings.Default.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; TUnitSettings.Default.Display.DetailedStackTrace = _savedDetailedStackTrace; TUnitSettings.Default.Execution.FailFast = _savedFailFast; + TUnitSettings.Default.Reporting.HtmlReportEnabled = _savedHtmlReportEnabled; + TUnitSettings.Default.Reporting.JsonReportEnabled = _savedJsonReportEnabled; + TUnitSettings.Default.Reporting.ArtifactUploadEnabled = _savedArtifactUploadEnabled; } [Test] @@ -53,6 +62,9 @@ public async Task Defaults_Are_Correct() await Assert.That(TUnitSettings.Default.Parallelism.MaximumParallelTests).IsNull(); await Assert.That(TUnitSettings.Default.Display.DetailedStackTrace).IsFalse(); await Assert.That(TUnitSettings.Default.Execution.FailFast).IsFalse(); + await Assert.That(TUnitSettings.Default.Reporting.HtmlReportEnabled).IsTrue(); + await Assert.That(TUnitSettings.Default.Reporting.JsonReportEnabled).IsTrue(); + await Assert.That(TUnitSettings.Default.Reporting.ArtifactUploadEnabled).IsTrue(); } [Test] @@ -62,6 +74,18 @@ public async Task Settings_Can_Be_Modified() await Assert.That(TUnitSettings.Default.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); } + [Test] + public async Task Reporting_Settings_Can_Be_Modified() + { + TUnitSettings.Default.Reporting.HtmlReportEnabled = false; + TUnitSettings.Default.Reporting.JsonReportEnabled = false; + TUnitSettings.Default.Reporting.ArtifactUploadEnabled = false; + + await Assert.That(TUnitSettings.Default.Reporting.HtmlReportEnabled).IsFalse(); + await Assert.That(TUnitSettings.Default.Reporting.JsonReportEnabled).IsFalse(); + await Assert.That(TUnitSettings.Default.Reporting.ArtifactUploadEnabled).IsFalse(); + } + // Covers TestCoordinator's `test.Timeout ?? TUnitSettings...ExplicitDefaultTestTimeout` fallback: // when the user never assigns DefaultTestTimeout, tests without [Timeout] skip the // TimeoutHelper wrapper entirely (the right-hand side of the coalesce is null).