diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md
index ad9ee85c755..d896f4e84cc 100644
--- a/docs/docs/reference/programmatic-configuration.md
+++ b/docs/docs/reference/programmatic-configuration.md
@@ -57,6 +57,8 @@ Settings are accessed exclusively through `context.Settings` in the discovery ho
| `ForcefulExitTimeout` | `TimeSpan` | 30 seconds | Grace period before the process is forcefully terminated after a cancellation. |
| `ProcessExitHookDelay` | `TimeSpan` | 500 ms | Delay before process-exit hooks run, allowing pending I/O to flush. |
+On Linux and macOS with .NET 8 or later, SIGTERM (sent by `docker stop`, Kubernetes, and CI cancellation) cancels the run the same way as Ctrl+C: After hooks run within `ForcefulExitTimeout`, and the run ends as a failed test session with exit code 10. .NET Framework test projects running on Mono keep the runtime's default handling, so After hooks there only get `ProcessExitHookDelay`.
+
### `context.Settings.Parallelism`
| Property | Type | Default | Description |
diff --git a/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs b/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs
index 4bc0f8e3be7..10be28209b8 100644
--- a/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs
+++ b/src/TUnit.Assertions/Conditions/DirectoryInfoAssertionExtensions.cs
@@ -24,6 +24,7 @@ file static partial class DirectoryInfoAssertionExtensions
public static bool IsHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == true;
[GenerateAssertion(ExpectationMessage = "to not be hidden", InlineMethodBody = true)]
public static bool IsNotHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == false;
+ /// The System attribute only exists on Windows; on Linux and macOS this never holds.
[GenerateAssertion(ExpectationMessage = "to be a system directory", InlineMethodBody = true)]
public static bool IsSystemDirectory(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
}
diff --git a/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs b/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs
index fdea55dcebb..7dc6a814f67 100644
--- a/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs
+++ b/src/TUnit.Assertions/Conditions/FileInfoAssertionExtensions.cs
@@ -26,8 +26,10 @@ file static partial class FileInfoAssertionExtensions
public static bool IsEmpty(this FileInfo value) => value?.Length == 0;
[GenerateAssertion(ExpectationMessage = "to not be empty", InlineMethodBody = true)]
public static bool IsNotEmpty(this FileInfo value) => value != null && value.Length > 0;
+ /// The System attribute only exists on Windows; on Linux and macOS this never holds.
[GenerateAssertion(ExpectationMessage = "to be a system file", InlineMethodBody = true)]
public static bool IsSystemFile(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
+ /// The Archive attribute is only maintained on Windows; on Linux and macOS this never holds.
[GenerateAssertion(ExpectationMessage = "to be archived", InlineMethodBody = true)]
public static bool IsArchived(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.Archive) == true;
}
diff --git a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs
index a81eb8f5a32..cde229327e5 100644
--- a/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs
+++ b/src/TUnit.Assertions/Conditions/FileSystemAssertions.cs
@@ -91,6 +91,9 @@ protected override Task CheckAsync(EvaluationMetadata
+/// The System attribute only exists on Windows; on Linux and macOS this assertion always passes.
+///
[AssertionExtension("IsNotSystem")]
public class FileIsNotSystemAssertion : Assertion
{
@@ -132,9 +135,15 @@ protected override Task CheckAsync(EvaluationMetadata
protected override string GetExpectation() => "to not be a system file";
}
+///
+/// On Linux and macOS a file counts as executable when any execute permission bit is set. On Windows,
+/// and on .NET Framework, it is judged by extension (.exe, .bat, .cmd, .com, .sh, .ps1).
+///
[AssertionExtension("IsNotExecutable")]
public class FileIsNotExecutableAssertion : Assertion
{
+ private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"];
+
public FileIsNotExecutableAssertion(
AssertionContext context)
: base(context)
@@ -162,10 +171,23 @@ protected override Task CheckAsync(EvaluationMetadata
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' does not exist"));
}
- var executableExtensions = new[] { ".exe", ".bat", ".cmd", ".com", ".sh", ".ps1" };
- var isExecutable = executableExtensions.Contains(value.Extension.ToLowerInvariant());
+#if NET
+ // On Unix, executability is the file mode, not the name: a 0755 "myapp" is executable and
+ // a 0644 "notes.sh" is not.
+ if (!OperatingSystem.IsWindows() && !OperatingSystem.IsBrowser())
+ {
+ const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+
+ if ((value.UnixFileMode & anyExecute) != 0)
+ {
+ return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (mode: {value.UnixFileMode})"));
+ }
+
+ return AssertionResult._passedTask;
+ }
+#endif
- if (isExecutable)
+ if (ExecutableExtensions.Contains(value.Extension, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (extension: {value.Extension})"));
}
diff --git a/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs b/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs
index dff08478920..ca96bbf1cb7 100644
--- a/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs
+++ b/src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs
@@ -53,10 +53,9 @@ public override Task ShouldSkip(TestRegisteredContext context)
// Check if the current platform matches any of the excluded operating systems
var shouldSkip =
OperatingSystem.HasFlag(OS.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
-#if NET
- // Only validate Linux and macOS on .NET 5+ where these OS flags are available
|| (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|| (OperatingSystem.HasFlag(OS.MacOs) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+#if NET
|| (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser())
#endif
;
diff --git a/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs b/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs
index 45444c9c512..5022f994b4a 100644
--- a/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs
+++ b/src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs
@@ -53,8 +53,9 @@ public override Task ShouldSkip(TestRegisteredContext context)
{
return Task.FromResult(false);
}
-#if NET
- // Only validate Linux and macOS on .NET 5+ where these OS flags are available
+
+ // RuntimeInformation.IsOSPlatform is available on netstandard2.0 too, so .NET Framework
+ // test projects running under Mono on Linux or macOS must reach these checks.
if (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return Task.FromResult(false);
@@ -65,6 +66,7 @@ public override Task ShouldSkip(TestRegisteredContext context)
return Task.FromResult(false);
}
+#if NET
// Check for Browser platform (WebAssembly)
if (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser())
{
diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs
index b4715fdbf9e..6d92c3b29ac 100644
--- a/src/TUnit.Core/EngineCancellationToken.cs
+++ b/src/TUnit.Core/EngineCancellationToken.cs
@@ -1,3 +1,6 @@
+#if NET
+using System.Runtime.InteropServices;
+#endif
using System.Runtime.Versioning;
using TUnit.Core.Settings;
@@ -20,8 +23,25 @@ public class EngineCancellationToken : IDisposable
private int _initialised;
private volatile bool _forcefulExitStarted;
+ private readonly object _forcefulExitGate = new();
+ private bool _disposed;
private CancellationTokenRegistration _platformRegistration;
private bool _cancelKeyPressSubscribed;
+#if NET
+ private PosixSignalRegistration? _sigtermRegistration;
+ private volatile bool _terminationSignalReceived;
+#endif
+
+ ///
+ /// Whether the run was cancelled by SIGTERM. Microsoft.Testing.Platform does not observe that
+ /// signal, so unlike Ctrl+C its own run token is not cancelled alongside ours.
+ ///
+ internal bool TerminationSignalReceived =>
+#if NET
+ _terminationSignalReceived;
+#else
+ false;
+#endif
public EngineCancellationToken()
{
@@ -29,7 +49,7 @@ public EngineCancellationToken()
}
///
- /// Hooks up process-wide cancellation signals (Ctrl+C / ProcessExit) the first time it's
+ /// Hooks up process-wide cancellation signals (Ctrl+C / SIGTERM / ProcessExit) the first time it's
/// called for this instance. Idempotent — subsequent calls are no-ops so that concurrent
/// MTP server-mode RPCs against one session don't clobber each other's cancellation chain.
/// Per-call cancellation flows through the explicit CancellationToken threaded into
@@ -67,7 +87,90 @@ internal void Initialise(CancellationToken platformCancellationToken = default)
#if NET5_0_OR_GREATER
}
#endif
+
+#if NET
+ _sigtermRegistration = TryRegisterSigterm();
+#endif
+ }
+
+#if NET
+ ///
+ /// SIGTERM is how Unix asks a process to stop (docker stop, Kubernetes, CI cancellation,
+ /// timeout). Left to the runtime it only raises , which
+ /// gives After hooks before the process
+ /// dies. Handling it like Ctrl+C instead cancels the run and allows the full
+ /// for cleanup. Windows is left alone:
+ /// there the signal maps to console close/shutdown events, which the runtime already handles.
+ ///
+ private PosixSignalRegistration? TryRegisterSigterm()
+ {
+ // Android, iOS and tvOS do not deliver POSIX signals to managed code.
+ if (OperatingSystem.IsWindows() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi()
+ || OperatingSystem.IsAndroid() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS())
+ {
+ return null;
+ }
+
+ try
+ {
+ return PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSigterm);
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return null;
+ }
+ }
+
+ private void OnSigterm(PosixSignalContext context)
+ {
+ // Suppress the default behaviour (immediate termination) only when a live session took the
+ // signal: the forceful-exit timer it armed still guarantees the process ends. A signal that
+ // lands after disposal keeps the runtime's default, so the process still stops.
+ // Only ever set it: every registered handler shares this context, and assigning false here
+ // would undo another, live session's suppression.
+ if (OnTerminationSignal())
+ {
+ context.Cancel = true;
+ }
+ }
+
+ /// Whether a live session handled the signal (and armed the forceful-exit timer).
+ internal bool OnTerminationSignal()
+ {
+ // Check-and-arm under the gate Dispose takes: either the session is already disposed and the
+ // signal is not handled, or the timer is armed before Dispose can complete.
+ lock (_forcefulExitGate)
+ {
+ if (_disposed)
+ {
+ return false;
+ }
+
+ _terminationSignalReceived = true;
+
+ if (!_forcefulExitStarted)
+ {
+ _forcefulExitStarted = true;
+ ArmForcefulExit();
+ }
+ }
+
+ // Runs cancellation callbacks (After hooks) synchronously on the signal-dispatch thread. A
+ // slow hook blocks that thread, which is acceptable: the timer armed above bounds it.
+ try
+ {
+ Cancel(armForcefulExit: false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Disposing the registration does not stop a dispatch the runtime had already
+ // snapshotted, so the signal can land after the session's token was disposed.
+ // An exception escaping the signal handler would crash the process.
+ }
+
+ return true;
}
+#endif
///
/// Subscribes to . Platforms without console signals
@@ -111,40 +214,49 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
private void Cancel(bool armForcefulExit = true)
{
- // Cancel the test execution
- if (!CancellationTokenSource.IsCancellationRequested)
- {
- CancellationTokenSource.Cancel();
- }
-
- // The forceful-exit timer is only for signal-driven cancellation (Ctrl+C), where we
+ // The forceful-exit timer is only for signal-driven cancellation (Ctrl+C / SIGTERM), where we
// suppressed the runtime's default termination and must guarantee the process still dies.
// Platform-driven cancellation manages its own shutdown, so it opts out.
- if (!armForcefulExit)
+ // Arm it BEFORE cancelling: Cancel() runs registered callbacks (After hooks) synchronously,
+ // and one that blocks must not postpone the deadline that is meant to bound it.
+ if (armForcefulExit)
{
- return;
+ // Under the gate shared with Dispose: a signal the runtime dispatches after the session
+ // was disposed must not arm a timer that would later kill a host still in use.
+ lock (_forcefulExitGate)
+ {
+ if (!_disposed && !_forcefulExitStarted)
+ {
+ _forcefulExitStarted = true;
+ ArmForcefulExit();
+ }
+ }
}
- // Only start the forceful exit timer once
- if (!_forcefulExitStarted)
+ // Cancel the test execution
+ if (!CancellationTokenSource.IsCancellationRequested)
{
- _forcefulExitStarted = true;
+ CancellationTokenSource.Cancel();
+ }
+ }
- // Start a new forceful exit timer
- _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t =>
+ // Virtual so tests can observe arming without terminating the test process.
+ internal virtual void ArmForcefulExit()
+ {
+ _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t =>
+ {
+ if (!t.IsCanceled)
{
- if (!t.IsCanceled)
- {
- Console.WriteLine("Forcefully terminating the process due to cancellation request.");
- Environment.Exit(1);
- }
- }, TaskScheduler.Default);
- }
+ Console.WriteLine("Forcefully terminating the process due to cancellation request.");
+ Environment.Exit(1);
+ }
+ }, TaskScheduler.Default);
}
private void OnProcessExit(object? sender, EventArgs e)
{
- // Process is exiting (SIGTERM, kill, etc.) - trigger cancellation to execute After hooks
+ // Process is exiting (Environment.Exit, end of Main, or a signal not handled above) - trigger
+ // cancellation to execute After hooks.
// Note: ProcessExit runs on a background thread with limited time (~3 seconds on Windows)
// The After hooks registered via CancellationToken.Register() will execute when we cancel
if (!CancellationTokenSource.IsCancellationRequested)
@@ -164,7 +276,16 @@ private void OnProcessExit(object? sender, EventArgs e)
///
public void Dispose()
{
+ lock (_forcefulExitGate)
+ {
+ _disposed = true;
+ }
+
_platformRegistration.Dispose();
+#if NET
+ _sigtermRegistration?.Dispose();
+ _sigtermRegistration = null;
+#endif
// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
diff --git a/src/TUnit.Core/Executors/STAThreadExecutor.cs b/src/TUnit.Core/Executors/STAThreadExecutor.cs
index 86be08a7ce7..cc4b063cb4a 100644
--- a/src/TUnit.Core/Executors/STAThreadExecutor.cs
+++ b/src/TUnit.Core/Executors/STAThreadExecutor.cs
@@ -1,4 +1,6 @@
-using System.Runtime.Versioning;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using TUnit.Core.Exceptions;
namespace TUnit.Core;
@@ -7,6 +9,16 @@ public class STAThreadExecutor : DedicatedThreadExecutor
{
protected override void ConfigureThread(Thread thread)
{
+ // [SupportedOSPlatform] only warns at compile time; without this guard a test run on
+ // Linux or macOS gets a bare PlatformNotSupportedException that names neither the
+ // executor nor the fix.
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ throw new TUnitException(
+ $"{nameof(STAThreadExecutor)} requires Windows: STA apartment threads are not supported on {RuntimeInformation.OSDescription}. " +
+ "Restrict the test with [RunOn(OS.Windows)] or use a different executor on this platform.");
+ }
+
thread.SetApartmentState(ApartmentState.STA);
}
}
diff --git a/src/TUnit.Engine/Exceptions/TUnitFailedException.cs b/src/TUnit.Engine/Exceptions/TUnitFailedException.cs
index cd18f06b7b9..566e4a3bec4 100644
--- a/src/TUnit.Engine/Exceptions/TUnitFailedException.cs
+++ b/src/TUnit.Engine/Exceptions/TUnitFailedException.cs
@@ -77,9 +77,12 @@ internal static string FilterStackTrace(string? stackTrace)
ReadOnlySpan pendingSeparator = default;
var hasPendingSeparator = false;
- foreach (var range in span.Split(Environment.NewLine))
+ // Split on '\n' and trim '\r' rather than splitting on Environment.NewLine: a trace captured
+ // on another OS (CRLF read on Unix, LF read on Windows) would otherwise be one long line and
+ // nothing would be filtered.
+ foreach (var range in span.Split('\n'))
{
- var slice = span[range];
+ var slice = span[range].TrimEnd('\r');
var trimmed = slice.Trim();
if (IsTUnitInternalFrame(trimmed))
diff --git a/src/TUnit.Engine/Framework/TUnitTestFramework.cs b/src/TUnit.Engine/Framework/TUnitTestFramework.cs
index 0158f893ca0..0370fca9c46 100644
--- a/src/TUnit.Engine/Framework/TUnitTestFramework.cs
+++ b/src/TUnit.Engine/Framework/TUnitTestFramework.cs
@@ -77,13 +77,32 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
serviceProvider.CancellationToken.Initialise(context.CancellationToken);
await _requestHandler.HandleRequestAsync((TestExecutionRequest) context.Request, serviceProvider, context, GetFilter(context));
+
+ // Cancellation does not always surface as an exception (a run can wind down cleanly),
+ // but a run stopped by SIGTERM must never report success.
+ if (serviceProvider.CancellationToken.TerminationSignalReceived)
+ {
+ await serviceProvider.Logger.LogErrorAsync(SigtermMessage);
+ serviceProvider.SessionFailed = true;
+ }
}
catch (Exception e) when (IsCancellationException(e))
{
+ var serviceProvider = GetOrCreateServiceProvider(context);
+
+ if (!context.CancellationToken.IsCancellationRequested && serviceProvider.CancellationToken.TerminationSignalReceived)
+ {
+ // MTP only treats the exception as a cancellation when its own token fired, which
+ // SIGTERM does not do; re-throwing would crash the host. Fail the session instead.
+ await serviceProvider.Logger.LogErrorAsync(SigtermMessage);
+ serviceProvider.SessionFailed = true;
+ return;
+ }
+
var message = context.CancellationToken.IsCancellationRequested
? "The test run was cancelled."
: "Test execution stopped due to fail-fast.";
- await GetOrCreateServiceProvider(context).Logger.LogErrorAsync(message);
+ await serviceProvider.Logger.LogErrorAsync(message);
// Re-throw is safe here — MTP handles OperationCanceledException specially.
throw;
@@ -110,7 +129,9 @@ public async Task CloseTestSessionAsync(CloseTestSession
if (_serviceProvidersPerSession.TryRemove(context.SessionUid.Value, out var serviceProvider))
{
- if (serviceProvider.SessionFailed)
+ // TerminationSignalReceived too: SIGTERM can arrive after the request finished but before
+ // the host closes the session, and a terminated run must never report success.
+ if (serviceProvider.SessionFailed || serviceProvider.CancellationToken.TerminationSignalReceived)
{
isSuccess = false;
}
@@ -134,6 +155,8 @@ private TUnitServiceProvider GetOrCreateServiceProvider(ExecuteRequestContext co
_capabilities));
}
+ private const string SigtermMessage = "The test run was cancelled by SIGTERM.";
+
private static bool IsCancellationException(Exception e)
{
return e is TaskCanceledException or OperationCanceledException;
diff --git a/src/TUnit.Engine/Helpers/FileLockDetection.cs b/src/TUnit.Engine/Helpers/FileLockDetection.cs
new file mode 100644
index 00000000000..1e1bb6402b4
--- /dev/null
+++ b/src/TUnit.Engine/Helpers/FileLockDetection.cs
@@ -0,0 +1,42 @@
+using System.Runtime.InteropServices;
+
+namespace TUnit.Engine.Helpers;
+
+///
+/// Recognises the thrown when another process holds a file, so report
+/// writers can retry instead of giving up.
+///
+internal static class FileLockDetection
+{
+ // Windows: the low word of the HResult is the Win32 error.
+ private const int ErrorSharingViolation = 0x20;
+ private const int ErrorLockViolation = 0x21;
+
+ // Unix: .NET takes an advisory flock for FileShare and reports contention with the raw errno
+ // as the HResult. EWOULDBLOCK (== EAGAIN) is 11 on Linux and 35 on the BSDs (macOS, FreeBSD,
+ // NetBSD, OpenBSD). Both are accepted everywhere rather than probing the OS: the other value is
+ // EDEADLK on each family, and treating that as contention only costs a bounded retry.
+ private const int EWouldBlockLinux = 11;
+ private const int EWouldBlockBsd = 35;
+
+ internal static bool IsFileLocked(IOException exception)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var errorCode = exception.HResult & 0xFFFF;
+
+ if (errorCode is ErrorSharingViolation or ErrorLockViolation)
+ {
+ return true;
+ }
+ }
+ else if (exception.HResult is EWouldBlockLinux or EWouldBlockBsd)
+ {
+ return true;
+ }
+
+ // Best-effort fallback for hosts that map the error differently. The runtime's message is
+ // English on .NET (Core); a localized .NET Framework message simply won't match.
+ return exception.Message.Contains("being used by another process");
+ }
+}
diff --git a/src/TUnit.Engine/Helpers/PathValidator.cs b/src/TUnit.Engine/Helpers/PathValidator.cs
index ac6ca09946c..7539ac2acf6 100644
--- a/src/TUnit.Engine/Helpers/PathValidator.cs
+++ b/src/TUnit.Engine/Helpers/PathValidator.cs
@@ -1,6 +1,7 @@
#if NET8_0_OR_GREATER
using System.Buffers;
#endif
+using System.Runtime.InteropServices;
namespace TUnit.Engine.Helpers;
@@ -92,7 +93,7 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa
{
var currentDir = Path.GetFullPath(Directory.GetCurrentDirectory());
- if (!fullPath.StartsWith(currentDir, StringComparison.OrdinalIgnoreCase))
+ if (!IsWithinDirectory(fullPath, currentDir))
{
throw new ArgumentException(
$"Relative path resolves outside the current working directory and is not allowed: '{validatedPath}'",
@@ -103,6 +104,30 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa
return fullPath;
}
+ ///
+ /// Whether is itself or lies beneath it.
+ /// Requires a separator at the boundary, so /foo/barbaz is not inside /foo/bar, and
+ /// folds case only on Windows: on a case-sensitive filesystem /foo/Bar is a different directory.
+ ///
+ internal static bool IsWithinDirectory(string fullPath, string directory)
+ {
+ var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ // A root ("/" or "C:\") trims to "" or "C:", after which the boundary check still holds.
+ var root = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ if (!fullPath.StartsWith(root, comparison))
+ {
+ return false;
+ }
+
+ return fullPath.Length == root.Length
+ || fullPath[root.Length] == Path.DirectorySeparatorChar
+ || fullPath[root.Length] == Path.AltDirectorySeparatorChar;
+ }
+
private static bool ContainsPathTraversal(string path)
{
// Check for ".." segments which indicate path traversal
diff --git a/src/TUnit.Engine/Reporters/GitHubReporter.cs b/src/TUnit.Engine/Reporters/GitHubReporter.cs
index 9348d130211..bdf9ebcafe1 100644
--- a/src/TUnit.Engine/Reporters/GitHubReporter.cs
+++ b/src/TUnit.Engine/Reporters/GitHubReporter.cs
@@ -532,14 +532,22 @@ private async Task WriteFile(string contents)
{
try
{
+ // FileShare.None rather than File.AppendAllText: on Unix the latter takes only a shared
+ // lock, so parallel test processes could append at the same offset and overwrite each
+ // other. An exclusive lock makes contenders fail and go through the retry below.
+ var bytes = Encoding.UTF8.GetBytes(contents);
+ using (var stream = new FileStream(_outputSummaryFilePath, FileMode.Append, FileAccess.Write, FileShare.None))
+ {
#if NET
- await File.AppendAllTextAsync(_outputSummaryFilePath, contents, Encoding.UTF8);
+ await stream.WriteAsync(bytes);
#else
- File.AppendAllText(_outputSummaryFilePath, contents, Encoding.UTF8);
+ await stream.WriteAsync(bytes, 0, bytes.Length);
#endif
+ }
+
return;
}
- catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex))
+ catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex))
{
var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1);
var jitter = random.Next(0, EngineDefaults.MaxRetryJitterMs);
@@ -551,17 +559,6 @@ private async Task WriteFile(string contents)
}
}
- private static bool IsFileLocked(IOException exception)
- {
- // Check if the exception is due to the file being locked/in use
- // HResult 0x80070020 is ERROR_SHARING_VIOLATION on Windows
- // HResult 0x80070021 is ERROR_LOCK_VIOLATION on Windows
- var errorCode = exception.HResult & 0xFFFF;
- return errorCode == 0x20 || errorCode == 0x21 ||
- exception.Message.Contains("being used by another process") ||
- exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase);
- }
-
private string GetDetails(IProperty? stateProperty, PropertyBag properties)
{
#pragma warning disable CS0618, MTP0001 // Retained for TUnit's GitHub cancellation reporting
@@ -665,7 +662,9 @@ private static string GetTestDisplayName(TestNode testNode)
// TUnit stores source line numbers 1-based (via [CallerLineNumber] / Roslyn span + 1),
// and they flow into LineSpan.Start.Line unchanged — so it is already 1-based here.
var line = fileLocation.LineSpan.Start.Line;
- var fileName = Path.GetFileName(fileLocation.FilePath);
+ // Normalise first: an assembly built on Windows and run on Linux carries a backslash-separated
+ // path, which Path.GetFileName does not split there.
+ var fileName = Path.GetFileName(fileLocation.FilePath.Replace('\\', '/'));
return $"[{fileName}:{line}]({serverUrl}/{repo}/blob/{sha}/{filePath}#L{line})";
}
diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 1622dbebf63..3691685d03c 100644
--- a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -1119,7 +1119,7 @@ private static async Task WriteFileAsync(string path, string content, Canc
Console.WriteLine($"HTML test report written to: {path}");
return true;
}
- catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex))
+ catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex))
{
var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1);
var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs);
@@ -1134,19 +1134,6 @@ private static async Task WriteFileAsync(string path, string content, Canc
return false;
}
- private static bool IsFileLocked(IOException exception)
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- var errorCode = exception.HResult & 0xFFFF;
- return errorCode is 0x20 or 0x21; // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION
- }
-
- // On POSIX, concurrent writers are less common; fallback to message heuristic
- return exception.Message.Contains("being used by another process") ||
- exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase);
- }
-
/// The uploaded artifact's URL, when the in-process upload succeeded.
private async Task TryGitHubIntegrationAsync(string filePath, CancellationToken cancellationToken)
{
diff --git a/src/TUnit.Engine/Reporters/JUnitReporter.cs b/src/TUnit.Engine/Reporters/JUnitReporter.cs
index 1aa23f0218b..0af6384dac3 100644
--- a/src/TUnit.Engine/Reporters/JUnitReporter.cs
+++ b/src/TUnit.Engine/Reporters/JUnitReporter.cs
@@ -150,7 +150,7 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell
Console.WriteLine($"JUnit XML report written to: {path}");
return;
}
- catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex))
+ catch (IOException ex) when (attempt < maxAttempts && FileLockDetection.IsFileLocked(ex))
{
var baseDelay = EngineDefaults.BaseRetryDelayMs * Math.Pow(2, attempt - 1);
var jitter = Random.Shared.Next(0, EngineDefaults.MaxRetryJitterMs);
@@ -163,15 +163,4 @@ private static async Task WriteXmlFileAsync(string path, string content, Cancell
Console.WriteLine($"Failed to write JUnit XML report to: {path} after {maxAttempts} attempts");
}
-
- private static bool IsFileLocked(IOException exception)
- {
- // Check if the exception is due to the file being locked/in use
- // HResult 0x80070020 is ERROR_SHARING_VIOLATION on Windows
- // HResult 0x80070021 is ERROR_LOCK_VIOLATION on Windows
- var errorCode = exception.HResult & 0xFFFF;
- return errorCode == 0x20 || errorCode == 0x21 ||
- exception.Message.Contains("being used by another process") ||
- exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase);
- }
}
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 93fc08ce911..34928892e2d 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -107,7 +107,7 @@ public override bool Execute()
// has no way to single one out. Same-identity duplicates collapse onto one copy.
var (identityGroups, unreadableMatches) = GroupMatchesByIdentity(matches);
- if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
+ if (matches.Select(m => m.ItemSpec).Distinct(PathComparer).Count() > 1)
{
var unreadableNote = unreadableMatches.Count > 0
? " References whose assembly identity cannot be read are left in place: " +
@@ -499,6 +499,9 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
StringComparison.OrdinalIgnoreCase;
#endif
+ private static StringComparer PathComparer =>
+ PathComparison == StringComparison.OrdinalIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
private static bool IsReferenceAssembly(string path)
{
using var module = ModuleDefinition.ReadModule(path);
diff --git a/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json b/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json
index ae6d71d792c..0c9f604f9c9 100644
--- a/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json
+++ b/src/TUnit.Templates/content/TUnit.AspNet/WebApp/Properties/launchSettings.json
@@ -1,13 +1,5 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:24820",
- "sslPort": 44340
- }
- },
"profiles": {
"http": {
"commandName": "Project",
@@ -28,14 +20,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "swagger",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
}
}
}
diff --git a/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs b/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs
index 33b3d22be84..421477b8c2a 100644
--- a/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs
+++ b/src/TUnit.Templates/content/TUnit.Playwright/Hooks.cs
@@ -12,6 +12,11 @@ public static void InstallPlaywright()
Environment.SetEnvironmentVariable("PWDEBUG", "1");
}
- Microsoft.Playwright.Program.Main(["install"]);
+ var exitCode = Microsoft.Playwright.Program.Main(["install"]);
+ if (exitCode != 0)
+ {
+ // On Linux, browsers also need system libraries: run `playwright install --with-deps` once.
+ throw new InvalidOperationException($"Playwright browser installation failed with exit code {exitCode}.");
+ }
}
}
diff --git a/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs b/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs
index 601e16b35a2..7eec6125c32 100644
--- a/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs
+++ b/tests/TUnit.Assertions.Tests/FileSystemAssertionTests.cs
@@ -111,6 +111,33 @@ public async Task Test_FileInfo_IsNotExecutable()
await Assert.That(file).IsNotExecutable();
}
+#if NET
+ [Test]
+ [ExcludeOn(OS.Windows)]
+ [System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
+ public async Task Test_FileInfo_IsNotExecutable_Fails_For_Executable_Mode_Without_Extension()
+ {
+ var file = new FileInfo(Path.Combine(_testDirectory, "myapp"));
+ File.WriteAllText(file.FullName, "#!/bin/sh");
+ File.SetUnixFileMode(file.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ await Assert.That(async () => await Assert.That(file).IsNotExecutable())
+ .Throws();
+ }
+
+ [Test]
+ [ExcludeOn(OS.Windows)]
+ [System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
+ public async Task Test_FileInfo_IsNotExecutable_Passes_For_Script_Without_Execute_Bit()
+ {
+ var file = new FileInfo(Path.Combine(_testDirectory, "notes.sh"));
+ File.WriteAllText(file.FullName, "echo hi");
+ File.SetUnixFileMode(file.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+
+ await Assert.That(file).IsNotExecutable();
+ }
+#endif
+
[Test]
public async Task Test_FileInfo_HasExtension()
{
diff --git a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs
index d8c31e7d26b..07b7407549f 100644
--- a/tests/TUnit.Engine.Tests/GitHubReporterTests.cs
+++ b/tests/TUnit.Engine.Tests/GitHubReporterTests.cs
@@ -392,6 +392,77 @@ public async Task AfterRunAsync_SourceLink_Uses_OneBased_Line_Without_Extra_Incr
}
}
+ [Test]
+ public async Task AfterRunAsync_SourceLink_Text_Is_The_File_Name_For_A_Windows_Built_Path()
+ {
+ // An assembly built on Windows and run on Linux/macOS carries a backslash-separated source
+ // path, which Path.GetFileName does not split there.
+ var (reporter, outputFile) = await SetupReporter();
+ Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", "thomhurst/TUnit");
+ Environment.SetEnvironmentVariable("GITHUB_SHA", "abc123");
+ Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", "https://github.com");
+
+ try
+ {
+ var message = new TestNodeUpdateMessage(
+ sessionUid: new SessionUid("test-session"),
+ testNode: new TestNode
+ {
+ Uid = new TestNodeUid("loc-win"),
+ DisplayName = "FailingTest",
+ Properties = new PropertyBag(
+ new FailedTestNodeStateProperty(new Exception("boom"), "boom"),
+ new TestMethodIdentifierProperty(
+ @namespace: "TestNamespace",
+ assemblyFullName: "TestAssembly",
+ typeName: "SampleTests",
+ methodName: "FailingTest",
+ parameterTypeFullNames: [],
+ returnTypeFullName: "System.Void",
+ methodArity: 0),
+ new TestFileLocationProperty(
+ @"D:\a\TUnit\src\SampleTests.cs",
+ new LinePositionSpan(new LinePosition(12, 0), new LinePosition(20, 0))))
+ });
+
+ await FeedTestMessages(reporter, message);
+ await reporter.AfterRunAsync(1, CancellationToken.None);
+
+ var output = await File.ReadAllTextAsync(outputFile);
+ output.ShouldContain("[SampleTests.cs:12](");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", null);
+ Environment.SetEnvironmentVariable("GITHUB_SHA", null);
+ Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", null);
+ }
+ }
+
+ [Test]
+ public async Task AfterRunAsync_Waits_For_Another_Writer_Holding_The_Summary_File()
+ {
+ // Parallel test processes append to one GITHUB_STEP_SUMMARY. The reporter must take the
+ // file exclusively and retry, rather than append alongside another writer: on Unix a
+ // shared-lock append from two processes can land at the same offset and overwrite.
+ var (reporter, outputFile) = await SetupReporter();
+ await FeedTestMessages(reporter, CreateFailedTestMessage("w1", "FailingTest", "SampleTests", new Exception("boom")));
+
+ Task afterRun;
+ using (new FileStream(outputFile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
+ {
+ afterRun = reporter.AfterRunAsync(1, CancellationToken.None);
+ await Task.Delay(100);
+
+ afterRun.IsCompleted.ShouldBeFalse("the reporter wrote while another writer held the file");
+ }
+
+ await afterRun;
+
+ var output = await File.ReadAllTextAsync(outputFile);
+ output.ShouldContain("FailingTest");
+ }
+
private string CreateTempFile()
{
var path = Path.GetTempFileName();
diff --git a/tests/TUnit.Engine.Tests/SigtermTests.cs b/tests/TUnit.Engine.Tests/SigtermTests.cs
new file mode 100644
index 00000000000..c447c78361a
--- /dev/null
+++ b/tests/TUnit.Engine.Tests/SigtermTests.cs
@@ -0,0 +1,89 @@
+using System.Globalization;
+using System.Text;
+using CliWrap;
+using Shouldly;
+using TUnit.Core.Enums;
+
+namespace TUnit.Engine.Tests;
+
+///
+/// SIGTERM is how Unix asks a process to stop (docker stop, Kubernetes, CI cancellation). It must
+/// cancel the run gracefully like Ctrl+C — After hooks get the full shutdown window — rather than
+/// leave the runtime to end the process after a brief ProcessExit delay.
+///
+[ExcludeOn(OS.Windows)]
+public class SigtermTests
+{
+ private static readonly string NetVersion = Environment.GetEnvironmentVariable("NET_VERSION") ?? "net10.0";
+
+ [Test]
+ [Arguments(false)]
+ [Arguments(true)]
+ public async Task Sigterm_Cancels_Gracefully_And_Runs_After_Hooks(bool reflection, CancellationToken cancellationToken)
+ {
+ var markerDirectory = Path.Combine(Path.GetTempPath(), $"TUnit_Sigterm_{Guid.NewGuid():N}");
+ var startedMarker = Path.Combine(markerDirectory, "started.txt");
+ var afterMarker = Path.Combine(markerDirectory, "after.txt");
+
+ var testProject = Sourcy.DotNet.Projects.TUnit_TestProject;
+ var executable = Path.Combine(testProject.DirectoryName!, "bin", "Release", NetVersion, "TUnit.TestProject");
+
+ var environmentVariables = EnvironmentVariables.DisableHtmlReporterForChildProcess();
+ environmentVariables["TUNIT_SIGTERM_MARKER_DIR"] = markerDirectory;
+
+ var output = new StringBuilder();
+ using var forcefulCancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+
+ var commandTask = Cli.Wrap(executable)
+ .WithArguments(["--treenode-filter", "/*/*/SigtermCancellationTests/*", .. reflection ? ["--reflection"] : Array.Empty()])
+ .WithWorkingDirectory(testProject.DirectoryName!)
+ .WithEnvironmentVariables(environmentVariables)
+ .WithStandardOutputPipe(PipeTarget.ToStringBuilder(output))
+ .WithStandardErrorPipe(PipeTarget.ToStringBuilder(output))
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteAsync(forcefulCancellation.Token);
+
+ try
+ {
+ while (!File.Exists(startedMarker))
+ {
+ commandTask.Task.IsCompleted.ShouldBeFalse($"The test process exited before the test started:{Environment.NewLine}{output}");
+ await Task.Delay(100, cancellationToken);
+ }
+
+ await Cli.Wrap("kill")
+ .WithArguments(["-TERM", commandTask.ProcessId.ToString(CultureInfo.InvariantCulture)])
+ .ExecuteAsync(cancellationToken);
+
+ var result = await commandTask;
+
+ File.Exists(afterMarker).ShouldBeTrue($"The After hook did not complete:{Environment.NewLine}{output}");
+ output.ToString().ShouldContain("The test run was cancelled by SIGTERM.");
+
+ // A failed session; not 143 (killed by SIGTERM itself) or 134 (aborted on an unhandled exception).
+ result.ExitCode.ShouldBe(10);
+ }
+ finally
+ {
+ // If the test bailed out before the child finished, stop it before deleting the
+ // directory its After hook writes to.
+ if (!commandTask.Task.IsCompleted)
+ {
+ forcefulCancellation.Cancel();
+ try
+ {
+ await commandTask;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: the child was killed.
+ }
+ }
+
+ if (Directory.Exists(markerDirectory))
+ {
+ Directory.Delete(markerDirectory, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs b/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs
index 23148ee6cb7..de47b227b4c 100644
--- a/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs
+++ b/tests/TUnit.Engine.Tests/StackTraceFilterTests.cs
@@ -233,4 +233,21 @@ public void FilteredTrace_HintHasCorrectFormat()
lines[0].ShouldContain("MyApp.Tests.MyTest");
lines[1].ShouldContain("--detailed-stacktrace");
}
+
+ [Test]
+ [Arguments("\r\n")]
+ [Arguments("\n")]
+ public void Trace_With_Either_Line_Ending_Is_Filtered(string lineEnding)
+ {
+ // A trace captured on another OS keeps that OS's line endings.
+ var stackTrace = string.Join(lineEnding,
+ " at MyApp.Tests.MyTest() in /src/Tests.cs:line 15",
+ " at TUnit.Core.RunHelpers.RunAsync()");
+
+ var result = TUnitFailedException.FilterStackTrace(stackTrace);
+
+ result.ShouldContain("MyApp.Tests.MyTest");
+ result.ShouldNotContain("TUnit.Core.RunHelpers");
+ result.ShouldNotContain("\r" + Environment.NewLine);
+ }
}
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 700aa682e98..4c8ed5125ec 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -199,6 +199,32 @@ public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004()
await Assert.That(supersededPaths).Contains(duplicate);
}
+ [Test]
+ public async Task Paths_Differing_Only_In_Case_Are_Distinct_References_On_A_Case_Sensitive_FileSystem()
+ {
+ var root = NewScratchDirectory();
+ var lowerDirectory = Path.Combine(root, "deps");
+ var upperDirectory = Path.Combine(root, "DEPS");
+ Directory.CreateDirectory(lowerDirectory);
+
+ // Windows, and macOS's default APFS volume, fold case: there both paths name one file.
+ Skip.When(Directory.Exists(upperDirectory), "Requires a case-sensitive file system.");
+
+ Directory.CreateDirectory(upperDirectory);
+ var lower = Path.Combine(lowerDirectory, TargetLibName + ".dll");
+ var upper = Path.Combine(upperDirectory, TargetLibName + ".dll");
+ File.Copy(TargetLibPath, lower);
+ File.Copy(TargetLibPath, upper);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(NewScratchDirectory(), TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(lower), new TaskItem(upper)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Select(w => w.Code)).Contains("TUMIA004");
+ }
+
[Test]
public async Task Duplicate_Requests_Produce_Single_Publicized_Reference()
{
diff --git a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json
index ae6d71d792c..0c9f604f9c9 100644
--- a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json
+++ b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.AspNet._.verified/TUnit.AspNet/WebApp/Properties/launchSettings.json
@@ -1,13 +1,5 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:24820",
- "sslPort": 44340
- }
- },
"profiles": {
"http": {
"commandName": "Project",
@@ -28,14 +20,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "swagger",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
}
}
}
diff --git a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs
index fdd5131d43d..a6e603b111f 100644
--- a/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs
+++ b/tests/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Playwright._.verified/TUnit.Playwright/Hooks.cs
@@ -12,6 +12,11 @@ public static void InstallPlaywright()
Environment.SetEnvironmentVariable("PWDEBUG", "1");
}
- Microsoft.Playwright.Program.Main(["install"]);
+ var exitCode = Microsoft.Playwright.Program.Main(["install"]);
+ if (exitCode != 0)
+ {
+ // On Linux, browsers also need system libraries: run `playwright install --with-deps` once.
+ throw new InvalidOperationException($"Playwright browser installation failed with exit code {exitCode}.");
+ }
}
}
diff --git a/tests/TUnit.TestProject/SigtermCancellationTests.cs b/tests/TUnit.TestProject/SigtermCancellationTests.cs
new file mode 100644
index 00000000000..890f3f93039
--- /dev/null
+++ b/tests/TUnit.TestProject/SigtermCancellationTests.cs
@@ -0,0 +1,39 @@
+namespace TUnit.TestProject;
+
+///
+/// Driven by TUnit.Engine.Tests' SigtermTests, which sets TUNIT_SIGTERM_MARKER_DIR and sends SIGTERM
+/// once the test has started. Without that variable the test returns immediately.
+///
+[Timeout(300_000)]
+public class SigtermCancellationTests
+{
+ private static readonly string? MarkerDirectory = Environment.GetEnvironmentVariable("TUNIT_SIGTERM_MARKER_DIR");
+
+ [Test]
+ public async Task Test_That_Runs_Until_Sigterm(CancellationToken cancellationToken)
+ {
+ if (MarkerDirectory is null)
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(MarkerDirectory);
+ await File.WriteAllTextAsync(Path.Combine(MarkerDirectory, "started.txt"), "Test started", cancellationToken);
+
+ await Task.Delay(TimeSpan.FromHours(1), cancellationToken);
+ }
+
+ [After(Test)]
+ public async Task Slow_Cleanup()
+ {
+ if (MarkerDirectory is null)
+ {
+ return;
+ }
+
+ // Longer than ProcessExitHookDelay (500ms), so it only completes when SIGTERM is handled as a
+ // graceful cancellation rather than left to the runtime's process-exit path.
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ await File.WriteAllTextAsync(Path.Combine(MarkerDirectory, "after.txt"), "After hook executed");
+ }
+}
diff --git a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs
index 99b9cfbeb8f..d66a742720f 100644
--- a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs
+++ b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs
@@ -56,6 +56,59 @@ public async Task Initialise_PropagatesOtherSubscriptionFailures()
await Assert.That(() => token.Initialise()).Throws();
}
+#if NET
+ [Test]
+ public async Task TerminationSignal_Arms_Forceful_Exit_Before_Cancellation_Callbacks_Run()
+ {
+ // Cancellation callbacks (After hooks) run synchronously inside Cancel(); a blocking one
+ // must not delay the deadline that bounds shutdown.
+ using var token = new RecordingForcefulExitToken();
+ bool? armedWhenCallbackRan = null;
+ token.Token.Register(() => armedWhenCallbackRan = token.ForcefulExitArmed);
+
+ var handled = token.OnTerminationSignal();
+
+ await Assert.That(handled).IsTrue();
+ await Assert.That(armedWhenCallbackRan).IsEqualTo(true);
+ await Assert.That(token.TerminationSignalReceived).IsTrue();
+ await Assert.That(token.Token.IsCancellationRequested).IsTrue();
+ }
+
+ [Test]
+ public async Task TerminationSignal_After_Dispose_Neither_Throws_Nor_Arms_Forceful_Exit()
+ {
+ // The runtime may dispatch a signal it captured before Dispose unregistered the handler.
+ // Unhandled, so OnSigterm leaves the runtime's default termination in place.
+ var token = new RecordingForcefulExitToken();
+ token.Dispose();
+
+ var handled = token.OnTerminationSignal();
+
+ await Assert.That(handled).IsFalse();
+ await Assert.That(token.ForcefulExitArmed).IsFalse();
+ }
+#endif
+
+ [Test]
+ public async Task PlatformCancellation_Does_Not_Arm_Forceful_Exit()
+ {
+ using var platform = new CancellationTokenSource();
+ using var token = new RecordingForcefulExitToken();
+ token.Initialise(platform.Token);
+
+ platform.Cancel();
+
+ await Assert.That(token.Token.IsCancellationRequested).IsTrue();
+ await Assert.That(token.ForcefulExitArmed).IsFalse();
+ }
+
+ private sealed class RecordingForcefulExitToken : RecordingCancelKeyPressToken
+ {
+ public bool ForcefulExitArmed { get; private set; }
+
+ internal override void ArmForcefulExit() => ForcefulExitArmed = true;
+ }
+
private class RecordingCancelKeyPressToken : EngineCancellationToken
{
public int SubscribeCalls { get; private set; }
diff --git a/tests/TUnit.UnitTests/FileLockDetectionTests.cs b/tests/TUnit.UnitTests/FileLockDetectionTests.cs
new file mode 100644
index 00000000000..ca326c79f18
--- /dev/null
+++ b/tests/TUnit.UnitTests/FileLockDetectionTests.cs
@@ -0,0 +1,64 @@
+using TUnit.Assertions.Extensions;
+using TUnit.Core.Enums;
+using TUnit.Engine.Helpers;
+
+namespace TUnit.UnitTests;
+
+public class FileLockDetectionTests
+{
+ [Test]
+ public async Task Contention_On_An_Exclusively_Held_File_Is_Detected()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"tunit-lock-{Guid.NewGuid():N}.txt");
+
+ try
+ {
+ using var holder = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
+
+ IOException? caught = null;
+ try
+ {
+ using var contender = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.None);
+ }
+ catch (IOException e)
+ {
+ caught = e;
+ }
+
+ await Assert.That(caught).IsNotNull();
+ await Assert.That(FileLockDetection.IsFileLocked(caught!)).IsTrue();
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ [RunOn(OS.Windows)]
+ public async Task Windows_Sharing_And_Lock_Violations_Are_Detected()
+ {
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070020)))).IsTrue();
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070021)))).IsTrue();
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", unchecked((int) 0x80070005)))).IsFalse();
+ }
+
+ [Test]
+ [ExcludeOn(OS.Windows)]
+ public async Task Unix_EWouldBlock_Is_Detected_For_Linux_And_Bsd_Numbering()
+ {
+ // EWOULDBLOCK is 11 on Linux and 35 on the BSDs; both are accepted on any Unix.
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 11))).IsTrue();
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 35))).IsTrue();
+ // EACCES is a permission error, not contention.
+ await Assert.That(FileLockDetection.IsFileLocked(new IOException("x", 13))).IsFalse();
+ }
+
+ [Test]
+ public async Task Unrelated_IOException_Is_Not_Treated_As_Locked()
+ {
+ var exception = new FileNotFoundException("missing", "nope.txt");
+
+ await Assert.That(FileLockDetection.IsFileLocked(exception)).IsFalse();
+ }
+}
diff --git a/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs b/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs
new file mode 100644
index 00000000000..1adfdb19fd0
--- /dev/null
+++ b/tests/TUnit.UnitTests/OperatingSystemAttributeTests.cs
@@ -0,0 +1,56 @@
+using System.Runtime.InteropServices;
+using TUnit.Assertions.Extensions;
+using TUnit.Core.Enums;
+
+namespace TUnit.UnitTests;
+
+public class OperatingSystemAttributeTests
+{
+ private static OS? CurrentOS =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OS.Windows
+ : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? OS.Linux
+ : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OS.MacOs
+ : null;
+
+ private static OS OtherDesktopOSes(OS current) => (OS.Windows | OS.Linux | OS.MacOs) & ~current;
+
+ [Test]
+ public async Task RunOn_Current_OS_Does_Not_Skip()
+ {
+ Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS.");
+
+ var skip = await new RunOnAttribute(CurrentOS!.Value).ShouldSkip(null!);
+
+ await Assert.That(skip).IsFalse();
+ }
+
+ [Test]
+ public async Task RunOn_Other_OSes_Skips()
+ {
+ Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS.");
+
+ var skip = await new RunOnAttribute(OtherDesktopOSes(CurrentOS!.Value)).ShouldSkip(null!);
+
+ await Assert.That(skip).IsTrue();
+ }
+
+ [Test]
+ public async Task ExcludeOn_Current_OS_Skips()
+ {
+ Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS.");
+
+ var skip = await new ExcludeOnAttribute(CurrentOS!.Value).ShouldSkip(null!);
+
+ await Assert.That(skip).IsTrue();
+ }
+
+ [Test]
+ public async Task ExcludeOn_Other_OSes_Does_Not_Skip()
+ {
+ Skip.When(CurrentOS is null, "Not running on Windows, Linux or macOS.");
+
+ var skip = await new ExcludeOnAttribute(OtherDesktopOSes(CurrentOS!.Value)).ShouldSkip(null!);
+
+ await Assert.That(skip).IsFalse();
+ }
+}
diff --git a/tests/TUnit.UnitTests/PathValidatorTests.cs b/tests/TUnit.UnitTests/PathValidatorTests.cs
index 40c143d19ca..63dc2ae5d2d 100644
--- a/tests/TUnit.UnitTests/PathValidatorTests.cs
+++ b/tests/TUnit.UnitTests/PathValidatorTests.cs
@@ -1,4 +1,5 @@
using TUnit.Assertions.Extensions;
+using TUnit.Core.Enums;
using TUnit.Engine.Helpers;
namespace TUnit.UnitTests;
@@ -71,4 +72,53 @@ public async Task SanitizeFileName_LongNameWithInvalidChar_ExercisesHeapBranch()
await Assert.That(result).IsEqualTo(prefix);
}
+
+ [Test]
+ public async Task IsWithinDirectory_Child_IsInside()
+ {
+ var root = Path.GetFullPath(Path.GetTempPath());
+ var child = Path.Combine(root, "reports", "out.html");
+
+ await Assert.That(PathValidator.IsWithinDirectory(child, root)).IsTrue();
+ }
+
+ [Test]
+ public async Task IsWithinDirectory_DirectoryItself_IsInside()
+ {
+ var root = Path.GetFullPath(Path.GetTempPath());
+
+ await Assert.That(PathValidator.IsWithinDirectory(root, root)).IsTrue();
+ }
+
+ [Test]
+ public async Task IsWithinDirectory_SiblingSharingPrefix_IsOutside()
+ {
+ var parent = Path.GetFullPath(Path.GetTempPath());
+ var directory = Path.Combine(parent, "bar");
+ var sibling = Path.Combine(parent, "barbaz", "file.txt");
+
+ await Assert.That(PathValidator.IsWithinDirectory(sibling, directory)).IsFalse();
+ }
+
+ [Test]
+ [ExcludeOn(OS.Windows)]
+ public async Task IsWithinDirectory_DifferentlyCasedSibling_IsOutside_On_CaseSensitive_Platforms()
+ {
+ await Assert.That(PathValidator.IsWithinDirectory("/work/Project/file.txt", "/work/project")).IsFalse();
+ }
+
+ [Test]
+ [RunOn(OS.Windows)]
+ public async Task IsWithinDirectory_FoldsCase_On_Windows()
+ {
+ await Assert.That(PathValidator.IsWithinDirectory(@"C:\Work\Project\file.txt", @"c:\work\project")).IsTrue();
+ }
+
+ [Test]
+ public async Task IsWithinDirectory_FileSystemRoot_ContainsEverything()
+ {
+ var root = Path.GetPathRoot(Path.GetFullPath(Path.GetTempPath()))!;
+
+ await Assert.That(PathValidator.IsWithinDirectory(Path.GetFullPath(Path.GetTempPath()), root)).IsTrue();
+ }
}
diff --git a/tests/TUnit.UnitTests/STAThreadExecutorTests.cs b/tests/TUnit.UnitTests/STAThreadExecutorTests.cs
new file mode 100644
index 00000000000..0bce69bca29
--- /dev/null
+++ b/tests/TUnit.UnitTests/STAThreadExecutorTests.cs
@@ -0,0 +1,23 @@
+using TUnit.Assertions.Extensions;
+using TUnit.Core.Enums;
+using TUnit.Core.Exceptions;
+
+namespace TUnit.UnitTests;
+
+public class STAThreadExecutorTests
+{
+ [Test]
+ [ExcludeOn(OS.Windows)]
+ public async Task Off_Windows_Throws_A_TUnitException_Naming_The_Remedy()
+ {
+#pragma warning disable CA1416 // Deliberately exercising the Windows-only executor off Windows.
+ var executor = new STAThreadExecutor();
+
+ var exception = await Assert.That(() => executor.ExecuteBeforeTestDiscoveryHook(null!, null!, () => default).AsTask())
+ .Throws();
+#pragma warning restore CA1416
+
+ await Assert.That(exception!.Message).Contains(nameof(STAThreadExecutor));
+ await Assert.That(exception.Message).Contains("[RunOn(OS.Windows)]");
+ }
+}