diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Output.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Output.cs new file mode 100644 index 0000000000..195b3fa662 --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Output.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; + +/// +/// Diagnostic message and output stream management of . +/// +internal sealed partial class TestContextImplementation +{ + private SynchronizedStringBuilder? _stdOutStringBuilder; + private SynchronizedStringBuilder? _stdErrStringBuilder; + private SynchronizedStringBuilder? _traceStringBuilder; + private SynchronizedStringBuilder? _testContextMessageStringBuilder; + + internal SynchronizedStringBuilder StandardOutputBuilder + => GetOrCreate(ref _stdOutStringBuilder); + + internal SynchronizedStringBuilder StandardErrorBuilder + => GetOrCreate(ref _stdErrStringBuilder); + + internal SynchronizedStringBuilder TraceBuilder + => GetOrCreate(ref _traceStringBuilder); + + private SynchronizedStringBuilder TestContextMessageBuilder + => GetOrCreate(ref _testContextMessageStringBuilder); + + private static SynchronizedStringBuilder GetOrCreate(ref SynchronizedStringBuilder? builder) + => LazyInitializer.EnsureInitialized(ref builder, static () => new())!; + + /// + /// When overridden in a derived class, used to write trace messages while the + /// test is running. + /// + /// The formatted string that contains the trace message. + public override void Write(string? message) + { + string? msg = message?.Replace("\0", "\\0"); + TestContextMessageBuilder.Append(msg); + WriteLive(msg, appendLine: false); + } + + /// + /// When overridden in a derived class, used to write trace messages while the + /// test is running. + /// + /// The string that contains the trace message. + /// Arguments to add to the trace message. + public override void Write(string format, params object?[] args) + { + string message = string.Format(CultureInfo.CurrentCulture, format.Replace("\0", "\\0"), args); + TestContextMessageBuilder.Append(message); + WriteLive(message, appendLine: false); + } + + /// + /// When overridden in a derived class, used to write trace messages while the + /// test is running. + /// + /// The formatted string that contains the trace message. + public override void WriteLine(string? message) + { + string? msg = message?.Replace("\0", "\\0"); + TestContextMessageBuilder.AppendLine(msg); + WriteLive(msg, appendLine: true); + } + + /// + /// When overridden in a derived class, used to write trace messages while the + /// test is running. + /// + /// The string that contains the trace message. + /// Arguments to add to the trace message. + public override void WriteLine(string format, params object?[] args) + { + string message = string.Format(CultureInfo.CurrentCulture, format.Replace("\0", "\\0"), args); + TestContextMessageBuilder.AppendLine(message); + WriteLive(message, appendLine: true); + } + + /// + /// Gets messages from the testContext writeLines. + /// + /// The test context messages added so far. + public string? GetDiagnosticMessages() + => _testContextMessageStringBuilder?.ToString(); + + /// + /// Clears the previous testContext writeline messages. + /// + public void ClearDiagnosticMessages() + => _testContextMessageStringBuilder?.Clear(); + + /// + public void SetDisplayName(string? displayName) + => TestDisplayName = displayName; + + /// + public override void DisplayMessage(MessageLevel messageLevel, string message) + => _messageLogger?.SendMessage(messageLevel, message); + + internal string? GetAndClearOutput() + => _stdOutStringBuilder?.GetAndClear(); + + internal string? GetAndClearError() + => _stdErrStringBuilder?.GetAndClear(); + + internal string? GetAndClearTrace() + => _traceStringBuilder?.GetAndClear(); +} diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Properties.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Properties.cs new file mode 100644 index 0000000000..5f186cdf12 --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.Properties.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; + +/// +/// Property bag, result files and lifecycle-property capture of . +/// +internal sealed partial class TestContextImplementation +{ +#if NET9_0_OR_GREATER + private readonly Lock _propertiesLock = new(); +#else + private readonly object _propertiesLock = new(); +#endif + + /// + /// List of result files associated with the test. + /// + private List? _testResultFiles; + + /// + public override IDictionary Properties => _properties; + + /// + /// Returns whether property with parameter name is present or not. + /// + /// The property name. + /// The property value. + /// True if found. + public bool TryGetPropertyValue(string propertyName, out object? propertyValue) + => _properties.TryGetValue(propertyName, out propertyValue); + + /// + /// Adds the parameter name/value pair to property bag. + /// + /// The property name. + /// The property value. + public void AddProperty(string propertyName, string propertyValue) + => _properties.Add(propertyName, propertyValue); + + /// + /// Merges the given properties into this context's property bag using indexer semantics + /// (existing keys are overwritten, except the per-context labels + /// and + /// , which are preserved). + /// Used to flow properties set during AssemblyInitialize / ClassInitialize + /// into subsequent contexts. + /// + /// Merge precedence: keys in WIN over keys already + /// present in this context's bag. This is intentional — lifecycle snapshots typically + /// flow on top of the seeded source-level parameters (e.g. TestRunParameters from + /// .runsettings), so a user's explicit assignment in AssemblyInitialize / + /// ClassInitialize overrides any same-named runsettings value for the rest of + /// the lifecycle (class init, tests, class cleanup, assembly cleanup). + /// + /// + /// The properties to merge in. May be . + internal void MergeProperties(IReadOnlyDictionary? propertiesToMerge) + { + if (propertiesToMerge is null or { Count: 0 }) + { + return; + } + + // Take the same internal lock as CaptureLifecycleProperties so a snapshot capture + // cannot race with a merge on the same context (which would otherwise corrupt the + // Dictionary iterator or cause a missed write). Writes via the public Properties + // indexer still bypass this lock - see the remarks on CaptureLifecycleProperties. + lock (_propertiesLock) + { + foreach (KeyValuePair kvp in propertiesToMerge) + { + // Never overwrite the per-context labels. + if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) + { + continue; + } + + _properties[kvp.Key] = kvp.Value; + } + } + } + + /// + /// Captures a snapshot of the current property bag, excluding the per-context labels + /// ( and + /// ). The returned dictionary is intended to be + /// stored on a TestAssemblyInfo / TestClassInfo and later merged into other + /// contexts via . + /// + /// Returns when there are no non-label properties to capture + /// (the common case when AssemblyInitialize / ClassInitialize do not set + /// properties on TestContext). already handles a + /// argument as a no-op, so callers need not special-case this. + /// + /// + /// The snapshot is shallow: keys and value references are copied as-is. Reference-type + /// values stored in the bag (e.g. a mocked file system, a connection pool, a list) are + /// shared across every context the snapshot is later merged into. Mutations of those + /// reference-type instances are visible everywhere. + /// + /// + /// Enumeration is performed under a private synchronization lock so that snapshot + /// capture is safe against concurrent calls to this method or + /// on the same context. Note: writes made via the public indexer + /// do NOT take this lock, so a lifecycle method that spawns a background thread which + /// keeps mutating past method return can still race with the + /// capture - that is treated as user error and is consistent with the pre-existing + /// thread-affinity expectation of AssemblyInitialize / ClassInitialize. + /// + /// + /// + /// A read-only snapshot of the current properties (excluding per-context labels), or + /// if there are no such properties to snapshot. + /// + internal IReadOnlyDictionary? CaptureLifecycleProperties() + { + Dictionary? snapshot = null; + lock (_propertiesLock) + { + foreach (KeyValuePair kvp in _properties) + { + if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) + { + continue; + } + +#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional. + snapshot ??= new Dictionary(_properties.Count); +#pragma warning restore IDE0028 + snapshot[kvp.Key] = kvp.Value; + } + } + + return snapshot is null ? null : new ReadOnlyDictionary(snapshot); + } + + /// + public override void AddResultFile(string fileName) + { + if (StringEx.IsNullOrEmpty(fileName)) + { + throw new ArgumentException(Resource.Common_CannotBeNullOrEmpty, nameof(fileName)); + } + + string fullPath = Path.GetFullPath(fileName); + (_testResultFiles ??= []).Add(fullPath); +#if !WINDOWS_UWP && !WIN_UI + // Remember when a registered result file lives inside the per-test temp directory. The + // framework consumes the result-file list during execution (before this context is + // disposed), so this flag — not the list — is what cleanup consults to avoid deleting a + // directory whose file the host still reports as an attachment. See ShouldRetainTestTempDirectory. + if (Volatile.Read(ref _testTempDirectoryCreated) + && _testTempDirectory is { Length: > 0 } tempDir + && IsPathUnderDirectory(fullPath, tempDir)) + { + _hasResultFileUnderTestTempDirectory = true; + } +#endif + } + + /// + /// Result files attached. + /// + /// Results files generated in run. + public IList? GetResultFiles() + { +#if !WINDOWS_UWP && !WIN_UI + // This is called once per execution attempt, and the last call before disposal reflects the + // reportable attempt's result files. Recompute (not accumulate) whether any of *this* + // attempt's result files live under the per-test temp directory, so a sticky value from an + // earlier retry attempt cannot force retention of an otherwise-passing final attempt. + _hasResultFileUnderTestTempDirectory = HasResultFileUnderTestTempDirectory(); +#endif + if (_testResultFiles is null || _testResultFiles.Count == 0) + { + return null; + } + + // Hand over the existing list to the caller (callers only enumerate it) and reset the field + // so data driven tests start with a fresh list on the next AddResultFile call. + // This avoids the copy that ToList() would do. + List results = _testResultFiles; + _testResultFiles = null; + + return results; + } +} diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectory.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectory.cs new file mode 100644 index 0000000000..51d2144f52 --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectory.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP && !WIN_UI + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; + +/// +/// Per-test temporary directory state and creation for . +/// +internal sealed partial class TestContextImplementation +{ + /// + /// Maximum length (in characters) of the readable, sanitized test-name portion of the + /// per-test temporary directory name. This is a cap: the actual budget is computed + /// adaptively from how much room the base path leaves (see ), + /// and is only allowed to grow up to this cap. + /// + private const int TestTempDirectoryNameMaxLength = 50; + + /// + /// Minimum readable-name budget worth keeping. If the base path is so deep that the adaptive + /// budget for the readable portion would drop below this floor, the implementation falls back to + /// the system temporary directory (which is short) rather than emit a barely-readable name that + /// still risks overflowing MAX_PATH. + /// + private const int TestTempDirectoryNameMinLength = 8; + + /// + /// Guards lazy creation of . + /// +#if NET9_0_OR_GREATER + private readonly Lock _testTempDirectoryLock = new(); +#else + private readonly object _testTempDirectoryLock = new(); +#endif + + /// + /// Whether this context represents an executing test (as opposed to an assembly/class + /// initialize or cleanup fixture context). is a *per-test* + /// scratch directory; fixture contexts are not per-test and are not always disposed (e.g. + /// ClassCleanupManager.ForceCleanup contexts), so creating a directory for them would + /// leak it. The getter returns when this is . + /// + private bool _isTestExecutionContext; + + /// + /// The lazily-created per-test temporary directory, or if it has not + /// been accessed (and therefore not created) yet. + /// + private string? _testTempDirectory; + + /// + /// Whether has been created. + /// + private bool _testTempDirectoryCreated; + + /// + /// Whether the test registered (via ) a result file that lives inside + /// the per-test temporary directory. Such a file is reported to the host as a result attachment + /// and is collected after this context is disposed, so the directory must be retained even on a + /// passing outcome. This flag is set eagerly in because the framework + /// consumes the result-file list during execution, before cleanup runs. + /// + private bool _hasResultFileUnderTestTempDirectory; + + /// + /// Whether cleanup of the per-test temporary directory has started (i.e. the context is being + /// or has been disposed). Once set, the getter must not create a new directory, otherwise a + /// late access from a background thread the test spawned could create a directory *after* + /// cleanup already ran, leaking it. Guarded by . + /// + private bool _testTempDirectoryCleanupStarted; + + /// + public override string? TestTempDirectory + { + get + { + // A per-test scratch directory only makes sense for an executing test. Fixture + // (assembly/class initialize and cleanup) contexts are not per-test and may never be + // disposed, so creating a directory for them would leak it — return null instead. + if (!_isTestExecutionContext) + { + return null; + } + + if (Volatile.Read(ref _testTempDirectoryCreated)) + { + return _testTempDirectory; + } + + lock (_testTempDirectoryLock) + { + if (!_testTempDirectoryCreated) + { + if (_testTempDirectoryCleanupStarted) + { + // The context is being (or has been) disposed. Creating a directory now + // would leak it, because cleanup has already inspected the state. Treat a + // post-cleanup access as a no-op and return null (the test has finished). + return null; + } + + _testTempDirectory = CreateTestTempDirectory(); + Volatile.Write(ref _testTempDirectoryCreated, true); + } + } + + return _testTempDirectory; + } + } + + /// + /// Creates the per-test temporary directory. The directory lives under the run's results + /// directory (so it is discoverable next to other run output); when no results directory is + /// configured this is the test assembly's output directory. On Windows the readable-name budget + /// is sized adaptively from how much room the base path leaves under MAX_PATH, and — when + /// the results directory is so deep that even a minimal readable name cannot preserve the + /// reserved headroom for the test's own files — the implementation falls back to the short + /// system temporary directory instead. It also falls back to the system temporary directory when + /// the chosen base directory cannot be written to (for example a read-only output directory), so + /// the property returns a usable path rather than throwing from the getter. + /// + private string CreateTestTempDirectory() + { + string? resultsDirectory = TestResultsDirectory is { Length: > 0 } results ? results : null; + string baseDirectory = resultsDirectory ?? Path.GetTempPath(); + bool baseIsTemp = resultsDirectory is null; + + // Size the readable-name budget so that base + '\' + name + '_' + suffix, plus the reserved + // headroom for the files the test writes inside, stays within MAX_PATH on Windows. On other + // operating systems path length is effectively a non-issue (per-component limit is 255 and + // our whole segment is well under that), so the readable name simply gets the full cap. + int nameBudget = TestTempDirectoryNameMaxLength; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + int available = ComputeReadableNameBudget(baseDirectory); + if (available < TestTempDirectoryNameMinLength && !baseIsTemp) + { + // The results directory is too deep to leave usable headroom; fall back to the + // short system temp directory so the test still gets room to write. + baseDirectory = Path.GetTempPath(); + baseIsTemp = true; + available = ComputeReadableNameBudget(baseDirectory); + } + + nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength); + } + + if (TryCreateTestTempDirectoryUnder(baseDirectory, nameBudget, out string created)) + { + return created; + } + + // The chosen base directory could not be written to (e.g. a read-only output directory). + // Fall back to the system temporary directory, which is writable, so the property still + // returns a usable path instead of throwing from the getter. + if (!baseIsTemp) + { + string tempBase = Path.GetTempPath(); + int tempBudget = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Math.Max(0, Math.Min(ComputeReadableNameBudget(tempBase), TestTempDirectoryNameMaxLength)) + : TestTempDirectoryNameMaxLength; + if (TryCreateTestTempDirectoryUnder(tempBase, tempBudget, out created)) + { + return created; + } + } + + // Could not create anywhere with a readable name; make one last attempt under the system + // temp directory with a plain Guid name and let any exception surface as a genuine error. + string fallback = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fallback); + return fallback; + } + + /// + /// Attempts to create a uniquely-named per-test temporary directory under . + /// Returns (rather than throwing) when the base directory cannot be + /// written to, so the caller can fall back to another location. + /// + private bool TryCreateTestTempDirectoryUnder(string baseDirectory, int nameBudget, out string createdPath) + { + string namePart = SanitizeTestTempDirectoryName(GetTestTempDirectoryNameSource(), nameBudget); + + // The suffix is a full 128-bit GUID, so two contexts choosing the same directory name is + // cryptographically negligible. Exists + CreateDirectory is not an atomic exclusive create, + // so the retry loop below is a belt-and-braces guard rather than a real necessity. + const int maxAttempts = 10; + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, TestTempDirectoryUniqueSuffixLength); + string candidateName = namePart.Length == 0 ? suffix : $"{namePart}_{suffix}"; + string candidate = Path.Combine(baseDirectory, candidateName); + if (Directory.Exists(candidate)) + { + continue; + } + + try + { + Directory.CreateDirectory(candidate); + createdPath = candidate; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // The base directory is not writable — a read-only output directory, or (on .NET + // Framework) a denied filesystem permission surfaced as SecurityException. Signal + // failure so the caller can fall back to the system temporary directory. Retrying a + // different name under the same base would not help, so bail out immediately. + createdPath = string.Empty; + return false; + } + } + + createdPath = string.Empty; + return false; + } +} + +#endif diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryCleanup.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryCleanup.cs new file mode 100644 index 0000000000..93b0f48b39 --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryCleanup.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP && !WIN_UI + +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; + +/// +/// Cleanup and retention policy of the per-test temporary directory of +/// . +/// +internal sealed partial class TestContextImplementation +{ + /// + /// Environment variable that, when set to a truthy value, retains every per-test temporary + /// directory (including those of passing tests) instead of deleting them. Used for debugging. + /// + private const string RetainTestTempDirectoryEnvironmentVariable = "MSTEST_TEST_TEMP_DIRECTORY_RETAIN"; + + /// + /// Deletes the per-test temporary directory unless the test failed or retention was requested. + /// Best-effort: it swallows all exceptions, so a passing test cannot be failed by a cleanup + /// error. It runs after the test has completed, so it cannot extend the test's own execution or + /// trip its timeout; note that the delete is synchronous, so exception swallowing is guaranteed + /// but bounded cleanup time is not (a pathologically stalled filesystem could make disposal + /// itself slow). + /// + private void CleanupTestTempDirectory() + { + // Read the lazy-init state under the same lock the getter writes it under. Without this + // acquire barrier, a directory created on a worker thread the test spawned (but did not + // join) could be observed as not-yet-created here, silently skipping cleanup and leaking + // the directory even on a passing test. + string? directory; + lock (_testTempDirectoryLock) + { + // Mark cleanup as started while holding the lock so a concurrent first getter that has + // not yet created the directory will see this and skip creation, instead of creating a + // directory after cleanup has already run (which would leak it). + _testTempDirectoryCleanupStarted = true; + + if (!_testTempDirectoryCreated || _testTempDirectory is not { Length: > 0 } createdDirectory) + { + return; + } + + directory = createdDirectory; + } + + if (ShouldRetainTestTempDirectory()) + { + return; + } + + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception ex) + { + // A leaked file handle, or a transient antivirus/indexer lock on Windows, can make the + // delete fail. This must never fail an otherwise passing test, so we swallow it and only + // surface the failure through the diagnostic trace log. The logging itself is guarded so + // that a misbehaving trace logger cannot let an exception escape Dispose either. + try + { + PlatformServiceProvider.Instance.AdapterTraceLogger.Warning( + "Failed to delete per-test temporary directory '{0}': {1}", directory, ex); + } + catch (Exception) + { + // Intentionally ignored: cleanup is best-effort and must never throw from Dispose. + } + } + } + + /// + /// Determines whether the per-test temporary directory should be kept: retained on any + /// non-passing outcome, when the test registered a result file living inside it, or when + /// retention is forced via the environment variable escape hatch. + /// + private bool ShouldRetainTestTempDirectory() + { + // Retain a failed (or otherwise non-passing) test's artifacts for inspection. + if (_outcome != UnitTestOutcome.Passed) + { + return true; + } + + // If the test registered a result file (via AddResultFile) that lives inside the temp + // directory, that file is referenced as a result attachment and is collected by the test + // host *after* this context is disposed. Deleting the directory now would leave the + // attachment pointing at a missing file, so retain it in that case. + if (_hasResultFileUnderTestTempDirectory) + { + return true; + } + + string? retain; + try + { + retain = Environment.GetEnvironmentVariable(RetainTestTempDirectoryEnvironmentVariable); + } + catch (System.Security.SecurityException) + { + // Environment access is restricted (possible on .NET Framework). Treat the retention + // override as unset so cleanup does not throw out of Dispose. + return false; + } + + return retain is "1" || string.Equals(retain, "true", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns whether is located inside . + /// + private static bool IsPathUnderDirectory(string filePath, string directory) + { + string normalizedDirectory = Path.GetFullPath(directory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + string normalizedFile = Path.GetFullPath(filePath); + return normalizedFile.StartsWith( + normalizedDirectory, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + /// + /// Returns whether any currently-registered result file lives inside the per-test temporary + /// directory. + /// + private bool HasResultFileUnderTestTempDirectory() + { + if (_testResultFiles is not { Count: > 0 } files + || !Volatile.Read(ref _testTempDirectoryCreated) + || _testTempDirectory is not { Length: > 0 } tempDir) + { + return false; + } + + foreach (string file in files) + { + if (IsPathUnderDirectory(file, tempDir)) + { + return true; + } + } + + return false; + } +} + +#endif diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryNaming.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryNaming.cs new file mode 100644 index 0000000000..dc649e957b --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.TempDirectoryNaming.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP && !WIN_UI + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; + +/// +/// Naming, sanitization and length budgeting of the per-test temporary directory of +/// . +/// +internal sealed partial class TestContextImplementation +{ + /// + /// Number of hexadecimal characters of the uniqueness suffix appended to the directory name. + /// This is a full 128-bit GUID (32 hex chars, Guid.ToString("N")) so that two contexts + /// choosing the same suffix is cryptographically negligible even at very large scales — the + /// pre-check plus Directory.CreateDirectory is not an + /// atomic exclusive create, so uniqueness must come from the entropy of the suffix rather than + /// from the check. + /// + private const int TestTempDirectoryUniqueSuffixLength = 32; + + /// + /// Characters that are reserved in a Windows file name. Sanitization strips these on every OS + /// (not just via , which on Unix returns only + /// / and NUL) so the generated directory name is portable and never accidentally embeds + /// a path separator or wildcard when the run is later inspected on a different platform. + /// + private const string WindowsReservedFileNameChars = "<>:\"/\\|?*"; + + /// + /// The Windows MAX_PATH limit. The feature targets this classic 260-character limit and + /// deliberately does not rely on long-path opt-in (LongPathsEnabled / \\?\), which + /// is not guaranteed to be enabled and is frequently not honored by external tools that E2E + /// tests shell out to. + /// + private const int WindowsMaxPath = 260; + + /// + /// Characters reserved inside the per-test temporary directory for the files the test + /// itself writes (e.g. subdir\result.json). The adaptive budget guarantees at least this + /// much headroom under MAX_PATH on Windows, so a test's own writes do not fail with a + /// baffling originating in user code. + /// + private const int TestTempDirectoryReservedHeadroom = 80; + + /// + /// Computes how many characters the readable portion of the directory name may use so that the + /// full path plus the reserved headroom for the test's own files fits within MAX_PATH. + /// May be negative when the base path alone already exhausts the budget. + /// + private static int ComputeReadableNameBudget(string baseDirectory) + // full path = base + separator + + '_' + ; reserve headroom for files inside. + => WindowsMaxPath + - TestTempDirectoryReservedHeadroom + - baseDirectory.Length + - 1 // directory separator between base and the temp directory name + - 1 // the '_' between the readable name and the unique suffix + - TestTempDirectoryUniqueSuffixLength; + + private string? GetTestTempDirectoryNameSource() + => !StringEx.IsNullOrEmpty(TestDisplayName) + ? TestDisplayName + : _properties.TryGetValue(TestNameLabel, out object? testName) && testName is string testNameString + ? testNameString + : null; + + /// + /// Sanitizes a test name into a safe, bounded path segment: invalid path characters and + /// whitespace become underscores, runs of underscores collapse, and the result is truncated to + /// characters. + /// + private static string SanitizeTestTempDirectoryName(string? name, int maxLength) + { + if (maxLength <= 0 || StringEx.IsNullOrEmpty(name)) + { + return string.Empty; + } + + char[] invalidChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(name.Length); + bool lastWasUnderscore = false; + foreach (char c in name) + { + if (char.IsWhiteSpace(c) || char.IsControl(c) + || Array.IndexOf(invalidChars, c) >= 0 + || WindowsReservedFileNameChars.IndexOf(c) >= 0) + { + if (!lastWasUnderscore) + { + builder.Append('_'); + lastWasUnderscore = true; + } + } + else + { + builder.Append(c); + lastWasUnderscore = false; + } + } + + string sanitized = builder.ToString().Trim('_'); + if (sanitized.Length > maxLength) + { + int cutLength = maxLength; + + // Avoid slicing through the middle of a surrogate pair, which would leave a lone + // surrogate in the directory name and can produce an invalid path segment. + if (char.IsHighSurrogate(sanitized[cutLength - 1])) + { + cutLength--; + } + + sanitized = sanitized.Substring(0, cutLength).TrimEnd('_'); + } + + return sanitized; + } +} + +#endif diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs index b0b301fa8f..f988b8b444 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs @@ -6,8 +6,6 @@ using System.Data.Common; #endif -using System.Collections.ObjectModel; - using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -23,145 +21,22 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; /// internal sealed partial class TestContextImplementation : TestContext, ITestContext, IDisposable { -#if !WINDOWS_UWP && !WIN_UI - /// - /// Environment variable that, when set to a truthy value, retains every per-test temporary - /// directory (including those of passing tests) instead of deleting them. Used for debugging. - /// - private const string RetainTestTempDirectoryEnvironmentVariable = "MSTEST_TEST_TEMP_DIRECTORY_RETAIN"; - - /// - /// Maximum length (in characters) of the readable, sanitized test-name portion of the - /// per-test temporary directory name. This is a cap: the actual budget is computed - /// adaptively from how much room the base path leaves (see ), - /// and is only allowed to grow up to this cap. - /// - private const int TestTempDirectoryNameMaxLength = 50; - - /// - /// Minimum readable-name budget worth keeping. If the base path is so deep that the adaptive - /// budget for the readable portion would drop below this floor, the implementation falls back to - /// the system temporary directory (which is short) rather than emit a barely-readable name that - /// still risks overflowing MAX_PATH. - /// - private const int TestTempDirectoryNameMinLength = 8; - - /// - /// Number of hexadecimal characters of the uniqueness suffix appended to the directory name. - /// This is a full 128-bit GUID (32 hex chars, Guid.ToString("N")) so that two contexts - /// choosing the same suffix is cryptographically negligible even at very large scales — the - /// pre-check plus Directory.CreateDirectory is not an - /// atomic exclusive create, so uniqueness must come from the entropy of the suffix rather than - /// from the check. - /// - private const int TestTempDirectoryUniqueSuffixLength = 32; - - /// - /// Characters that are reserved in a Windows file name. Sanitization strips these on every OS - /// (not just via , which on Unix returns only - /// / and NUL) so the generated directory name is portable and never accidentally embeds - /// a path separator or wildcard when the run is later inspected on a different platform. - /// - private const string WindowsReservedFileNameChars = "<>:\"/\\|?*"; - - /// - /// The Windows MAX_PATH limit. The feature targets this classic 260-character limit and - /// deliberately does not rely on long-path opt-in (LongPathsEnabled / \\?\), which - /// is not guaranteed to be enabled and is frequently not honored by external tools that E2E - /// tests shell out to. - /// - private const int WindowsMaxPath = 260; - - /// - /// Characters reserved inside the per-test temporary directory for the files the test - /// itself writes (e.g. subdir\result.json). The adaptive budget guarantees at least this - /// much headroom under MAX_PATH on Windows, so a test's own writes do not fail with a - /// baffling originating in user code. - /// - private const int TestTempDirectoryReservedHeadroom = 80; -#endif - /// /// Properties. /// private readonly Dictionary _properties; -#if NET9_0_OR_GREATER - private readonly Lock _propertiesLock = new(); -#else - private readonly object _propertiesLock = new(); -#endif private readonly IAdapterMessageLogger? _messageLogger; private readonly TestRunCancellationToken? _testRunCancellationToken; private readonly TextWriter? _liveOutputWriter; private readonly Func _outputCaptureModeProvider; -#if !WINDOWS_UWP && !WIN_UI - /// - /// Guards lazy creation of . - /// -#if NET9_0_OR_GREATER - private readonly Lock _testTempDirectoryLock = new(); -#else - private readonly object _testTempDirectoryLock = new(); -#endif -#endif - private CancellationTokenRegistration? _cancellationTokenRegistration; - /// - /// List of result files associated with the test. - /// - private List? _testResultFiles; - - private SynchronizedStringBuilder? _stdOutStringBuilder; - private SynchronizedStringBuilder? _stdErrStringBuilder; - private SynchronizedStringBuilder? _traceStringBuilder; - private SynchronizedStringBuilder? _testContextMessageStringBuilder; - /// /// Unit test outcome. /// private UnitTestOutcome _outcome; -#if !WINDOWS_UWP && !WIN_UI - /// - /// Whether this context represents an executing test (as opposed to an assembly/class - /// initialize or cleanup fixture context). is a *per-test* - /// scratch directory; fixture contexts are not per-test and are not always disposed (e.g. - /// ClassCleanupManager.ForceCleanup contexts), so creating a directory for them would - /// leak it. The getter returns when this is . - /// - private bool _isTestExecutionContext; - - /// - /// The lazily-created per-test temporary directory, or if it has not - /// been accessed (and therefore not created) yet. - /// - private string? _testTempDirectory; - - /// - /// Whether has been created. - /// - private bool _testTempDirectoryCreated; - - /// - /// Whether the test registered (via ) a result file that lives inside - /// the per-test temporary directory. Such a file is reported to the host as a result attachment - /// and is collected after this context is disposed, so the directory must be retained even on a - /// passing outcome. This flag is set eagerly in because the framework - /// consumes the result-file list during execution, before cleanup runs. - /// - private bool _hasResultFileUnderTestTempDirectory; - - /// - /// Whether cleanup of the per-test temporary directory has started (i.e. the context is being - /// or has been disposed). Once set, the getter must not create a new directory, otherwise a - /// late access from a background thread the test spawned could create a directory *after* - /// cleanup already ran, leaking it. Guarded by . - /// - private bool _testTempDirectoryCleanupStarted; -#endif - #if NETFRAMEWORK /// /// DB connection for test context. @@ -261,129 +136,11 @@ private TestContextImplementation( public override DataRow? DataRow => _dataRow; #endif - /// - public override IDictionary Properties => _properties; - -#if !WINDOWS_UWP && !WIN_UI - /// - public override string? TestTempDirectory - { - get - { - // A per-test scratch directory only makes sense for an executing test. Fixture - // (assembly/class initialize and cleanup) contexts are not per-test and may never be - // disposed, so creating a directory for them would leak it — return null instead. - if (!_isTestExecutionContext) - { - return null; - } - - if (Volatile.Read(ref _testTempDirectoryCreated)) - { - return _testTempDirectory; - } - - lock (_testTempDirectoryLock) - { - if (!_testTempDirectoryCreated) - { - if (_testTempDirectoryCleanupStarted) - { - // The context is being (or has been) disposed. Creating a directory now - // would leak it, because cleanup has already inspected the state. Treat a - // post-cleanup access as a no-op and return null (the test has finished). - return null; - } - - _testTempDirectory = CreateTestTempDirectory(); - Volatile.Write(ref _testTempDirectoryCreated, true); - } - } - - return _testTempDirectory; - } - } -#endif - /// /// Gets the inner test context object. /// public TestContext Context => this; - /// - public override void AddResultFile(string fileName) - { - if (StringEx.IsNullOrEmpty(fileName)) - { - throw new ArgumentException(Resource.Common_CannotBeNullOrEmpty, nameof(fileName)); - } - - string fullPath = Path.GetFullPath(fileName); - (_testResultFiles ??= []).Add(fullPath); -#if !WINDOWS_UWP && !WIN_UI - // Remember when a registered result file lives inside the per-test temp directory. The - // framework consumes the result-file list during execution (before this context is - // disposed), so this flag — not the list — is what cleanup consults to avoid deleting a - // directory whose file the host still reports as an attachment. See ShouldRetainTestTempDirectory. - if (Volatile.Read(ref _testTempDirectoryCreated) - && _testTempDirectory is { Length: > 0 } tempDir - && IsPathUnderDirectory(fullPath, tempDir)) - { - _hasResultFileUnderTestTempDirectory = true; - } -#endif - } - - /// - /// When overridden in a derived class, used to write trace messages while the - /// test is running. - /// - /// The formatted string that contains the trace message. - public override void Write(string? message) - { - string? msg = message?.Replace("\0", "\\0"); - TestContextMessageBuilder.Append(msg); - WriteLive(msg, appendLine: false); - } - - /// - /// When overridden in a derived class, used to write trace messages while the - /// test is running. - /// - /// The string that contains the trace message. - /// Arguments to add to the trace message. - public override void Write(string format, params object?[] args) - { - string message = string.Format(CultureInfo.CurrentCulture, format.Replace("\0", "\\0"), args); - TestContextMessageBuilder.Append(message); - WriteLive(message, appendLine: false); - } - - /// - /// When overridden in a derived class, used to write trace messages while the - /// test is running. - /// - /// The formatted string that contains the trace message. - public override void WriteLine(string? message) - { - string? msg = message?.Replace("\0", "\\0"); - TestContextMessageBuilder.AppendLine(msg); - WriteLive(msg, appendLine: true); - } - - /// - /// When overridden in a derived class, used to write trace messages while the - /// test is running. - /// - /// The string that contains the trace message. - /// Arguments to add to the trace message. - public override void WriteLine(string format, params object?[] args) - { - string message = string.Format(CultureInfo.CurrentCulture, format.Replace("\0", "\\0"), args); - TestContextMessageBuilder.AppendLine(message); - WriteLive(message, appendLine: true); - } - /// /// Set the unit-test outcome. /// @@ -423,168 +180,6 @@ public void SetDataConnection(object? dbConnection) #pragma warning restore IDE0022 // Use expression body for method #endif } - - /// - /// Returns whether property with parameter name is present or not. - /// - /// The property name. - /// The property value. - /// True if found. - public bool TryGetPropertyValue(string propertyName, out object? propertyValue) - => _properties.TryGetValue(propertyName, out propertyValue); - - /// - /// Adds the parameter name/value pair to property bag. - /// - /// The property name. - /// The property value. - public void AddProperty(string propertyName, string propertyValue) - => _properties.Add(propertyName, propertyValue); - - /// - /// Merges the given properties into this context's property bag using indexer semantics - /// (existing keys are overwritten, except the per-context labels - /// and - /// , which are preserved). - /// Used to flow properties set during AssemblyInitialize / ClassInitialize - /// into subsequent contexts. - /// - /// Merge precedence: keys in WIN over keys already - /// present in this context's bag. This is intentional — lifecycle snapshots typically - /// flow on top of the seeded source-level parameters (e.g. TestRunParameters from - /// .runsettings), so a user's explicit assignment in AssemblyInitialize / - /// ClassInitialize overrides any same-named runsettings value for the rest of - /// the lifecycle (class init, tests, class cleanup, assembly cleanup). - /// - /// - /// The properties to merge in. May be . - internal void MergeProperties(IReadOnlyDictionary? propertiesToMerge) - { - if (propertiesToMerge is null or { Count: 0 }) - { - return; - } - - // Take the same internal lock as CaptureLifecycleProperties so a snapshot capture - // cannot race with a merge on the same context (which would otherwise corrupt the - // Dictionary iterator or cause a missed write). Writes via the public Properties - // indexer still bypass this lock - see the remarks on CaptureLifecycleProperties. - lock (_propertiesLock) - { - foreach (KeyValuePair kvp in propertiesToMerge) - { - // Never overwrite the per-context labels. - if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) - { - continue; - } - - _properties[kvp.Key] = kvp.Value; - } - } - } - - /// - /// Captures a snapshot of the current property bag, excluding the per-context labels - /// ( and - /// ). The returned dictionary is intended to be - /// stored on a TestAssemblyInfo / TestClassInfo and later merged into other - /// contexts via . - /// - /// Returns when there are no non-label properties to capture - /// (the common case when AssemblyInitialize / ClassInitialize do not set - /// properties on TestContext). already handles a - /// argument as a no-op, so callers need not special-case this. - /// - /// - /// The snapshot is shallow: keys and value references are copied as-is. Reference-type - /// values stored in the bag (e.g. a mocked file system, a connection pool, a list) are - /// shared across every context the snapshot is later merged into. Mutations of those - /// reference-type instances are visible everywhere. - /// - /// - /// Enumeration is performed under a private synchronization lock so that snapshot - /// capture is safe against concurrent calls to this method or - /// on the same context. Note: writes made via the public indexer - /// do NOT take this lock, so a lifecycle method that spawns a background thread which - /// keeps mutating past method return can still race with the - /// capture - that is treated as user error and is consistent with the pre-existing - /// thread-affinity expectation of AssemblyInitialize / ClassInitialize. - /// - /// - /// - /// A read-only snapshot of the current properties (excluding per-context labels), or - /// if there are no such properties to snapshot. - /// - internal IReadOnlyDictionary? CaptureLifecycleProperties() - { - Dictionary? snapshot = null; - lock (_propertiesLock) - { - foreach (KeyValuePair kvp in _properties) - { - if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) - { - continue; - } - -#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional. - snapshot ??= new Dictionary(_properties.Count); -#pragma warning restore IDE0028 - snapshot[kvp.Key] = kvp.Value; - } - } - - return snapshot is null ? null : new ReadOnlyDictionary(snapshot); - } - - /// - /// Result files attached. - /// - /// Results files generated in run. - public IList? GetResultFiles() - { -#if !WINDOWS_UWP && !WIN_UI - // This is called once per execution attempt, and the last call before disposal reflects the - // reportable attempt's result files. Recompute (not accumulate) whether any of *this* - // attempt's result files live under the per-test temp directory, so a sticky value from an - // earlier retry attempt cannot force retention of an otherwise-passing final attempt. - _hasResultFileUnderTestTempDirectory = HasResultFileUnderTestTempDirectory(); -#endif - if (_testResultFiles is null || _testResultFiles.Count == 0) - { - return null; - } - - // Hand over the existing list to the caller (callers only enumerate it) and reset the field - // so data driven tests start with a fresh list on the next AddResultFile call. - // This avoids the copy that ToList() would do. - List results = _testResultFiles; - _testResultFiles = null; - - return results; - } - - /// - /// Gets messages from the testContext writeLines. - /// - /// The test context messages added so far. - public string? GetDiagnosticMessages() - => _testContextMessageStringBuilder?.ToString(); - - /// - /// Clears the previous testContext writeline messages. - /// - public void ClearDiagnosticMessages() - => _testContextMessageStringBuilder?.Clear(); - - /// - public void SetDisplayName(string? displayName) - => TestDisplayName = displayName; - - /// - public override void DisplayMessage(MessageLevel messageLevel, string message) - => _messageLogger?.SendMessage(messageLevel, message); #endregion /// @@ -597,347 +192,6 @@ public void Dispose() #endif } -#if !WINDOWS_UWP && !WIN_UI - /// - /// Creates the per-test temporary directory. The directory lives under the run's results - /// directory (so it is discoverable next to other run output); when no results directory is - /// configured this is the test assembly's output directory. On Windows the readable-name budget - /// is sized adaptively from how much room the base path leaves under MAX_PATH, and — when - /// the results directory is so deep that even a minimal readable name cannot preserve the - /// reserved headroom for the test's own files — the implementation falls back to the short - /// system temporary directory instead. It also falls back to the system temporary directory when - /// the chosen base directory cannot be written to (for example a read-only output directory), so - /// the property returns a usable path rather than throwing from the getter. - /// - private string CreateTestTempDirectory() - { - string? resultsDirectory = TestResultsDirectory is { Length: > 0 } results ? results : null; - string baseDirectory = resultsDirectory ?? Path.GetTempPath(); - bool baseIsTemp = resultsDirectory is null; - - // Size the readable-name budget so that base + '\' + name + '_' + suffix, plus the reserved - // headroom for the files the test writes inside, stays within MAX_PATH on Windows. On other - // operating systems path length is effectively a non-issue (per-component limit is 255 and - // our whole segment is well under that), so the readable name simply gets the full cap. - int nameBudget = TestTempDirectoryNameMaxLength; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - int available = ComputeReadableNameBudget(baseDirectory); - if (available < TestTempDirectoryNameMinLength && !baseIsTemp) - { - // The results directory is too deep to leave usable headroom; fall back to the - // short system temp directory so the test still gets room to write. - baseDirectory = Path.GetTempPath(); - baseIsTemp = true; - available = ComputeReadableNameBudget(baseDirectory); - } - - nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength); - } - - if (TryCreateTestTempDirectoryUnder(baseDirectory, nameBudget, out string created)) - { - return created; - } - - // The chosen base directory could not be written to (e.g. a read-only output directory). - // Fall back to the system temporary directory, which is writable, so the property still - // returns a usable path instead of throwing from the getter. - if (!baseIsTemp) - { - string tempBase = Path.GetTempPath(); - int tempBudget = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Math.Max(0, Math.Min(ComputeReadableNameBudget(tempBase), TestTempDirectoryNameMaxLength)) - : TestTempDirectoryNameMaxLength; - if (TryCreateTestTempDirectoryUnder(tempBase, tempBudget, out created)) - { - return created; - } - } - - // Could not create anywhere with a readable name; make one last attempt under the system - // temp directory with a plain Guid name and let any exception surface as a genuine error. - string fallback = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(fallback); - return fallback; - } - - /// - /// Attempts to create a uniquely-named per-test temporary directory under . - /// Returns (rather than throwing) when the base directory cannot be - /// written to, so the caller can fall back to another location. - /// - private bool TryCreateTestTempDirectoryUnder(string baseDirectory, int nameBudget, out string createdPath) - { - string namePart = SanitizeTestTempDirectoryName(GetTestTempDirectoryNameSource(), nameBudget); - - // The suffix is a full 128-bit GUID, so two contexts choosing the same directory name is - // cryptographically negligible. Exists + CreateDirectory is not an atomic exclusive create, - // so the retry loop below is a belt-and-braces guard rather than a real necessity. - const int maxAttempts = 10; - for (int attempt = 0; attempt < maxAttempts; attempt++) - { - string suffix = Guid.NewGuid().ToString("N").Substring(0, TestTempDirectoryUniqueSuffixLength); - string candidateName = namePart.Length == 0 ? suffix : $"{namePart}_{suffix}"; - string candidate = Path.Combine(baseDirectory, candidateName); - if (Directory.Exists(candidate)) - { - continue; - } - - try - { - Directory.CreateDirectory(candidate); - createdPath = candidate; - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) - { - // The base directory is not writable — a read-only output directory, or (on .NET - // Framework) a denied filesystem permission surfaced as SecurityException. Signal - // failure so the caller can fall back to the system temporary directory. Retrying a - // different name under the same base would not help, so bail out immediately. - createdPath = string.Empty; - return false; - } - } - - createdPath = string.Empty; - return false; - } - - /// - /// Computes how many characters the readable portion of the directory name may use so that the - /// full path plus the reserved headroom for the test's own files fits within MAX_PATH. - /// May be negative when the base path alone already exhausts the budget. - /// - private static int ComputeReadableNameBudget(string baseDirectory) - // full path = base + separator + + '_' + ; reserve headroom for files inside. - => WindowsMaxPath - - TestTempDirectoryReservedHeadroom - - baseDirectory.Length - - 1 // directory separator between base and the temp directory name - - 1 // the '_' between the readable name and the unique suffix - - TestTempDirectoryUniqueSuffixLength; - - private string? GetTestTempDirectoryNameSource() - => !StringEx.IsNullOrEmpty(TestDisplayName) - ? TestDisplayName - : _properties.TryGetValue(TestNameLabel, out object? testName) && testName is string testNameString - ? testNameString - : null; - - /// - /// Sanitizes a test name into a safe, bounded path segment: invalid path characters and - /// whitespace become underscores, runs of underscores collapse, and the result is truncated to - /// characters. - /// - private static string SanitizeTestTempDirectoryName(string? name, int maxLength) - { - if (maxLength <= 0 || StringEx.IsNullOrEmpty(name)) - { - return string.Empty; - } - - char[] invalidChars = Path.GetInvalidFileNameChars(); - var builder = new StringBuilder(name.Length); - bool lastWasUnderscore = false; - foreach (char c in name) - { - if (char.IsWhiteSpace(c) || char.IsControl(c) - || Array.IndexOf(invalidChars, c) >= 0 - || WindowsReservedFileNameChars.IndexOf(c) >= 0) - { - if (!lastWasUnderscore) - { - builder.Append('_'); - lastWasUnderscore = true; - } - } - else - { - builder.Append(c); - lastWasUnderscore = false; - } - } - - string sanitized = builder.ToString().Trim('_'); - if (sanitized.Length > maxLength) - { - int cutLength = maxLength; - - // Avoid slicing through the middle of a surrogate pair, which would leave a lone - // surrogate in the directory name and can produce an invalid path segment. - if (char.IsHighSurrogate(sanitized[cutLength - 1])) - { - cutLength--; - } - - sanitized = sanitized.Substring(0, cutLength).TrimEnd('_'); - } - - return sanitized; - } - - /// - /// Deletes the per-test temporary directory unless the test failed or retention was requested. - /// Best-effort: it swallows all exceptions, so a passing test cannot be failed by a cleanup - /// error. It runs after the test has completed, so it cannot extend the test's own execution or - /// trip its timeout; note that the delete is synchronous, so exception swallowing is guaranteed - /// but bounded cleanup time is not (a pathologically stalled filesystem could make disposal - /// itself slow). - /// - private void CleanupTestTempDirectory() - { - // Read the lazy-init state under the same lock the getter writes it under. Without this - // acquire barrier, a directory created on a worker thread the test spawned (but did not - // join) could be observed as not-yet-created here, silently skipping cleanup and leaking - // the directory even on a passing test. - string? directory; - lock (_testTempDirectoryLock) - { - // Mark cleanup as started while holding the lock so a concurrent first getter that has - // not yet created the directory will see this and skip creation, instead of creating a - // directory after cleanup has already run (which would leak it). - _testTempDirectoryCleanupStarted = true; - - if (!_testTempDirectoryCreated || _testTempDirectory is not { Length: > 0 } createdDirectory) - { - return; - } - - directory = createdDirectory; - } - - if (ShouldRetainTestTempDirectory()) - { - return; - } - - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch (Exception ex) - { - // A leaked file handle, or a transient antivirus/indexer lock on Windows, can make the - // delete fail. This must never fail an otherwise passing test, so we swallow it and only - // surface the failure through the diagnostic trace log. The logging itself is guarded so - // that a misbehaving trace logger cannot let an exception escape Dispose either. - try - { - PlatformServiceProvider.Instance.AdapterTraceLogger.Warning( - "Failed to delete per-test temporary directory '{0}': {1}", directory, ex); - } - catch (Exception) - { - // Intentionally ignored: cleanup is best-effort and must never throw from Dispose. - } - } - } - - /// - /// Determines whether the per-test temporary directory should be kept: retained on any - /// non-passing outcome, when the test registered a result file living inside it, or when - /// retention is forced via the environment variable escape hatch. - /// - private bool ShouldRetainTestTempDirectory() - { - // Retain a failed (or otherwise non-passing) test's artifacts for inspection. - if (_outcome != UnitTestOutcome.Passed) - { - return true; - } - - // If the test registered a result file (via AddResultFile) that lives inside the temp - // directory, that file is referenced as a result attachment and is collected by the test - // host *after* this context is disposed. Deleting the directory now would leave the - // attachment pointing at a missing file, so retain it in that case. - if (_hasResultFileUnderTestTempDirectory) - { - return true; - } - - string? retain; - try - { - retain = Environment.GetEnvironmentVariable(RetainTestTempDirectoryEnvironmentVariable); - } - catch (System.Security.SecurityException) - { - // Environment access is restricted (possible on .NET Framework). Treat the retention - // override as unset so cleanup does not throw out of Dispose. - return false; - } - - return retain is "1" || string.Equals(retain, "true", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Returns whether is located inside . - /// - private static bool IsPathUnderDirectory(string filePath, string directory) - { - string normalizedDirectory = Path.GetFullPath(directory) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - string normalizedFile = Path.GetFullPath(filePath); - return normalizedFile.StartsWith( - normalizedDirectory, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - - /// - /// Returns whether any currently-registered result file lives inside the per-test temporary - /// directory. - /// - private bool HasResultFileUnderTestTempDirectory() - { - if (_testResultFiles is not { Count: > 0 } files - || !Volatile.Read(ref _testTempDirectoryCreated) - || _testTempDirectory is not { Length: > 0 } tempDir) - { - return false; - } - - foreach (string file in files) - { - if (IsPathUnderDirectory(file, tempDir)) - { - return true; - } - } - - return false; - } -#endif - - internal SynchronizedStringBuilder StandardOutputBuilder - => GetOrCreate(ref _stdOutStringBuilder); - - internal SynchronizedStringBuilder StandardErrorBuilder - => GetOrCreate(ref _stdErrStringBuilder); - - internal SynchronizedStringBuilder TraceBuilder - => GetOrCreate(ref _traceStringBuilder); - - private SynchronizedStringBuilder TestContextMessageBuilder - => GetOrCreate(ref _testContextMessageStringBuilder); - - private static SynchronizedStringBuilder GetOrCreate(ref SynchronizedStringBuilder? builder) - => LazyInitializer.EnsureInitialized(ref builder, static () => new())!; - - internal string? GetAndClearOutput() - => _stdOutStringBuilder?.GetAndClear(); - - internal string? GetAndClearError() - => _stdErrStringBuilder?.GetAndClear(); - - internal string? GetAndClearTrace() - => _traceStringBuilder?.GetAndClear(); - /// /// Creates a sibling for use by a single iteration /// of the folded data-driven test execution path.