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)]"); + } +}