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.