Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,18 @@
<PackageVersion Include="Testcontainers.Redis" Version="4.9.0" />
<PackageVersion Include="trxparser" Version="0.5.0" />
<PackageVersion Include="TUnit.Assertions.FSharp" Version="0.75.38-PullRequest3485.0" />
<PackageVersion Include="Verify" Version="31.7.3" />
<PackageVersion Include="Verify.NUnit" Version="31.7.3" />
<PackageVersion Include="Verify" Version="31.7.2" />
<PackageVersion Include="Verify.NUnit" Version="31.7.2" />
<PackageVersion Include="TUnit" Version="1.2.11" />
<PackageVersion Include="TUnit.Core" Version="1.2.11" />
<PackageVersion Include="TUnit.Assertions" Version="1.2.11" />
<PackageVersion Include="Verify.TUnit" Version="31.7.3" />
<PackageVersion Include="Verify.TUnit" Version="31.7.2" />
<PackageVersion Include="Vogen" Version="8.0.3" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assert" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.1" />
<PackageVersion Include="xunit.v3.assert" Version="3.2.1" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.1" />
<PackageVersion Include="xunit.v3" Version="3.2.0" />
<PackageVersion Include="xunit.v3.assert" Version="3.2.0" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.0" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Text;

namespace TUnit.Analyzers.Tests.Verifiers;

Expand All @@ -16,6 +17,12 @@ public class Test : CSharpCodeFixTest<TAnalyzer, TCodeFix, LineEndingNormalizing
{
public Test()
{
// Add EditorConfig to force LF line endings for cross-platform consistency
TestState.AnalyzerConfigFiles.Add(("/.editorconfig", SourceText.From("""
is_global = true
end_of_line = lf
""")));

SolutionTransforms.Add((solution, projectId) =>
{
var project = solution.GetProject(projectId);
Expand Down
12 changes: 11 additions & 1 deletion TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ public void NotEmpty<T>(string collectionName, IEnumerable<T> collection)

public void SequenceEqual<T>(IEnumerable<T> expected, IEnumerable<T> actual, IEqualityComparer<T>? equalityComparer = null, string? message = null)
{
_defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
// Normalize line endings for string sequence comparisons
if (typeof(T) == typeof(string))
{
var normalizedExpected = expected.Cast<string>().Select(NormalizeLineEndings).Cast<T>();
var normalizedActual = actual.Cast<string>().Select(NormalizeLineEndings).Cast<T>();
_defaultVerifier.SequenceEqual(normalizedExpected, normalizedActual, equalityComparer, message);
}
else
{
_defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
}
}

public IVerifier PushContext(string context)
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Assertions/Chaining/AndAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ private string BuildCombinedExpectation()
var becausePrefix = firstBecause.StartsWith("because ", StringComparison.OrdinalIgnoreCase)
? firstBecause
: $"because {firstBecause}";
return $"{firstExpectation}, {becausePrefix}{Environment.NewLine}and {secondExpectation}";
return $"{firstExpectation}, {becausePrefix}\nand {secondExpectation}";
}

return $"{firstExpectation}{Environment.NewLine}and {secondExpectation}";
return $"{firstExpectation}\nand {secondExpectation}";
}

protected override string GetExpectation() => "both conditions";
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Assertions/Chaining/OrAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ private string BuildCombinedExpectation()
var becausePrefix = firstBecause.StartsWith("because ", StringComparison.OrdinalIgnoreCase)
? firstBecause
: $"because {firstBecause}";
return $"{firstExpectation}, {becausePrefix}{Environment.NewLine}or {secondExpectation}";
return $"{firstExpectation}, {becausePrefix}\nor {secondExpectation}";
}

return $"{firstExpectation}{Environment.NewLine}or {secondExpectation}";
return $"{firstExpectation}\nor {secondExpectation}";
}

protected override string GetExpectation() => "either condition";
Expand Down
6 changes: 2 additions & 4 deletions TUnit.Assertions/Extensions/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
/// Example: await Assert.That(myObject).Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.IsNotEmpty());
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
/// </summary>
[OverloadResolutionPriority(1)]
[OverloadResolutionPriority(2)]
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TKey, TValue, TTransformed> overload with strongly-typed assertions.")]
public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
this IAssertionSource<TObject> source,
Expand Down Expand Up @@ -421,7 +421,7 @@ public static MemberAssertionResult<TObject> Member<TObject, TItem>(
/// Example: await Assert.That(myObject).Member(x => x.Tags, tags => tags.HasCount(1).And.Contains("value"));
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
/// </summary>
[OverloadResolutionPriority(0)]
[OverloadResolutionPriority(1)]
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TItem, TTransformed> overload with strongly-typed assertions.")]
public static MemberAssertionResult<TObject> Member<TObject, TItem>(
this IAssertionSource<TObject> source,
Expand Down Expand Up @@ -527,7 +527,6 @@ public static MemberAssertionResult<TObject> Member<TObject, TMember, TTransform
/// After the member assertion completes, returns to the parent object context for further chaining.
/// Example: await Assert.That(myObject).Member(x => x.PropertyName, value => value.IsEqualTo(expectedValue));
/// </summary>
[OverloadResolutionPriority(0)]
public static MemberAssertionResult<TObject> Member<TObject, TMember>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, TMember>> memberSelector,
Expand Down Expand Up @@ -580,7 +579,6 @@ public static MemberAssertionResult<TObject> Member<TObject, TMember>(
/// Example: await Assert.That(myObject).Member(x => x.PropertyName, value => value.IsEqualTo(expectedValue));
/// Note: This overload exists for backward compatibility. For AOT compatibility, use the TTransformed overload instead.
/// </summary>
[OverloadResolutionPriority(-1)]
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TMember, TTransformed> overload with strongly-typed assertions.")]
public static MemberAssertionResult<TObject> Member<TObject, TMember>(
this IAssertionSource<TObject> source,
Expand Down
17 changes: 17 additions & 0 deletions TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal void Initialise(CancellationToken cancellationToken)
{
#endif
Console.CancelKeyPress += OnCancelKeyPress;
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
#if NET5_0_OR_GREATER
}
#endif
Expand Down Expand Up @@ -71,6 +72,21 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
e.Cancel = true;
}

private void OnProcessExit(object? sender, EventArgs e)
{
// Process is exiting (SIGTERM, kill, etc.) - 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)
{
CancellationTokenSource.Cancel();

// Give After hooks a brief moment to execute via registered callbacks
// ProcessExit has limited time, so we can only wait briefly
Task.Delay(TimeSpan.FromMilliseconds(500)).GetAwaiter().GetResult();
}
}

/// <summary>
/// Disposes the cancellation token source.
/// </summary>
Expand All @@ -82,6 +98,7 @@ public void Dispose()
{
#endif
Console.CancelKeyPress -= OnCancelKeyPress;
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
#if NET5_0_OR_GREATER
}
#endif
Expand Down
5 changes: 0 additions & 5 deletions TUnit.Core/Executors/GenericAbstractExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,6 @@ public ValueTask ExecuteAfterTestHook(MethodMetadata hookMethodInfo, TestContext
return ExecuteAsync(action);
}

public ValueTask ExecuteDisposal(TestContext context, Func<ValueTask> action)
{
return ExecuteAsync(action);
}

public ValueTask ExecuteTest(TestContext context, Func<ValueTask> action)
{
return ExecuteAsync(action);
Expand Down
7 changes: 0 additions & 7 deletions TUnit.Core/Interfaces/IHookExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,4 @@ public interface IHookExecutor
ValueTask ExecuteAfterAssemblyHook(MethodMetadata hookMethodInfo, AssemblyHookContext context, Func<ValueTask> action);
ValueTask ExecuteAfterClassHook(MethodMetadata hookMethodInfo, ClassHookContext context, Func<ValueTask> action);
ValueTask ExecuteAfterTestHook(MethodMetadata hookMethodInfo, TestContext context, Func<ValueTask> action);

#if NETSTANDARD2_0
ValueTask ExecuteDisposal(TestContext context, Func<ValueTask> action);
#else
ValueTask ExecuteDisposal(TestContext context, Func<ValueTask> action)
=> action();
#endif
}
122 changes: 122 additions & 0 deletions TUnit.Engine.Tests/CancellationAfterHooksTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;
using TUnit.Engine.Tests.Extensions;

namespace TUnit.Engine.Tests;

/// <summary>
/// Validates that After hooks execute even when tests are cancelled (Issue #3882).
/// These tests run the cancellation test scenarios and verify that After hooks created marker files.
/// </summary>
public class CancellationAfterHooksTests(TestMode testMode) : InvokableTestBase(testMode)
{
private static readonly string TempPath = Path.GetTempPath();

[Test]
public async Task TestLevel_AfterHook_Runs_OnCancellation()
{
var afterMarkerFile = Path.Combine(TempPath, "TUnit_3882_Tests", "after_Test_ThatGets_Cancelled.txt");

// Clean up any existing marker files
if (File.Exists(afterMarkerFile))
{
File.Delete(afterMarkerFile);
}

await RunTestsWithFilter(
"/*/*/CancellationAfterHooksTests/*",
[
// Test run completes even though the test itself fails (timeout is expected)
result => result.ResultSummary.Counters.Total.ShouldBe(1),
// Test should fail due to timeout
result => result.ResultSummary.Counters.Failed.ShouldBe(1),
// After hook should have created the marker file - this proves After hooks ran on cancellation
_ => File.Exists(afterMarkerFile).ShouldBeTrue($"After hook marker file should exist at {afterMarkerFile}")
]);

// Verify marker file content
if (File.Exists(afterMarkerFile))
{
var content = await File.ReadAllTextAsync(afterMarkerFile);
content.ShouldContain("After hook executed");
}
}

[Test]
public async Task SessionLevel_AfterHook_Runs_OnCancellation()
{
var afterMarkerFile = Path.Combine(TempPath, "TUnit_3882_Session_After.txt");

// Clean up any existing marker files
if (File.Exists(afterMarkerFile))
{
File.Delete(afterMarkerFile);
}

await RunTestsWithFilter(
"/*/*/SessionLevelCancellationTests/*",
[
// After Session hook should have created the marker file - this proves Session After hooks ran on cancellation
_ => File.Exists(afterMarkerFile).ShouldBeTrue($"Session After hook marker file should exist at {afterMarkerFile}")
]);

// Verify marker file content
if (File.Exists(afterMarkerFile))
{
var content = await File.ReadAllTextAsync(afterMarkerFile);
content.ShouldContain("Session After hook executed");
}
}

[Test]
public async Task AssemblyLevel_AfterHook_Runs_OnCancellation()
{
var afterMarkerFile = Path.Combine(TempPath, "TUnit_3882_Assembly_After.txt");

// Clean up any existing marker files
if (File.Exists(afterMarkerFile))
{
File.Delete(afterMarkerFile);
}

await RunTestsWithFilter(
"/*/*/AssemblyLevelCancellationTests/*",
[
// After Assembly hook should have created the marker file - this proves Assembly After hooks ran on cancellation
_ => File.Exists(afterMarkerFile).ShouldBeTrue($"Assembly After hook marker file should exist at {afterMarkerFile}")
]);

// Verify marker file content
if (File.Exists(afterMarkerFile))
{
var content = await File.ReadAllTextAsync(afterMarkerFile);
content.ShouldContain("Assembly After hook executed");
}
}

[Test]
public async Task ClassLevel_AfterHook_Runs_OnCancellation()
{
var afterMarkerFile = Path.Combine(TempPath, "TUnit_3882_Class_After.txt");

// Clean up any existing marker files
if (File.Exists(afterMarkerFile))
{
File.Delete(afterMarkerFile);
}

await RunTestsWithFilter(
"/*/*/ClassLevelCancellationTests/*",
[
// After Class hook should have created the marker file - this proves Class After hooks ran on cancellation
_ => File.Exists(afterMarkerFile).ShouldBeTrue($"Class After hook marker file should exist at {afterMarkerFile}")
]);

// Verify marker file content
if (File.Exists(afterMarkerFile))
{
var content = await File.ReadAllTextAsync(afterMarkerFile);
content.ShouldContain("Class After hook executed");
}
}
}
Loading
Loading