Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
236 changes: 236 additions & 0 deletions TUnit.Engine.Tests/StackTraceFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
using Shouldly;
using TUnit.Engine.Exceptions;

namespace TUnit.Engine.Tests;

public class StackTraceFilterTests
{
[Test]
public void UserFrames_BeforeTUnitFrames_ArePreserved()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.UserService.GetUser(Int32 id) in C:\\src\\UserService.cs:line 42",
" at MyApp.Tests.UserTests.TestGetUser() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("MyApp.UserService.GetUser");
result.ShouldContain("MyApp.Tests.UserTests.TestGetUser");
result.ShouldNotContain("TUnit.Core.RunHelpers");
result.ShouldNotContain("TUnit.Engine.TestExecutor");
}

[Test]
public void UserFrames_AfterTUnitFrames_ArePreserved()
{
// Assertion internals at top, user test method below
var stackTrace = string.Join(Environment.NewLine,
" at TUnit.Assertions.AssertCondition.Check()",
" at TUnit.Assertions.AssertionBuilder.Process()",
" at MyApp.Tests.UserTests.TestGetUser() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("MyApp.Tests.UserTests.TestGetUser");
result.ShouldNotContain("TUnit.Assertions.AssertCondition");
result.ShouldNotContain("TUnit.Core.RunHelpers");
}

[Test]
public void AllTUnitFrames_PreservesFullTrace()
{
// TUnit bug — no user frames at all
var stackTrace = string.Join(Environment.NewLine,
" at TUnit.Engine.SomeService.Process()",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldBe(stackTrace);
}

[Test]
public void EmptyStackTrace_ReturnsEmpty()
{
TUnitFailedException.FilterStackTrace(null).ShouldBeEmpty();
TUnitFailedException.FilterStackTrace("").ShouldBeEmpty();
}

[Test]
public void OmittedFrames_ShowHint()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.Tests.UserTests.TestGetUser() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("--detailed-stacktrace");
}

[Test]
public void NoTUnitFrames_NoHint()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.UserService.GetUser(Int32 id) in C:\\src\\UserService.cs:line 42",
" at MyApp.Tests.UserTests.TestGetUser() in C:\\src\\Tests.cs:line 15");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldNotContain("--detailed-stacktrace");
result.ShouldBe(stackTrace);
}

[Test]
public void TUnitWithoutDot_IsNotFiltered()
{
// "TUnitExtensions" is NOT a TUnit internal namespace
var stackTrace = string.Join(Environment.NewLine,
" at TUnitExtensions.MyHelper.DoSomething()",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("TUnitExtensions.MyHelper");
result.ShouldNotContain("TUnit.Core.RunHelpers");
}

[Test]
public void InterleavedFrames_PreservesAllUserFrames()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.Database.Connect() in C:\\src\\Database.cs:line 10",
" at TUnit.Core.SomeHelper.DoSomething()",
" at MyApp.Tests.TestBase.Setup() in C:\\src\\TestBase.cs:line 5",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("MyApp.Database.Connect");
result.ShouldContain("MyApp.Tests.TestBase.Setup");
result.ShouldNotContain("TUnit.Core.SomeHelper");
result.ShouldNotContain("TUnit.Engine.TestExecutor");
}

[Test]
public void AsyncInfrastructureLines_ArePreserved()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.UserService.GetUser(Int32 id) in C:\\src\\UserService.cs:line 42",
"--- End of stack trace from previous location ---",
" at MyApp.Tests.UserTests.TestGetUser() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("End of stack trace from previous location");
result.ShouldContain("MyApp.UserService.GetUser");
result.ShouldContain("MyApp.Tests.UserTests.TestGetUser");
}

[Test]
public void AllTUnitFrames_WithAsyncSeparators_PreservesFullTrace()
{
// TUnit bug with async separators — should still return full trace
var stackTrace = string.Join(Environment.NewLine,
" at TUnit.Engine.SomeService.Process()",
"--- End of stack trace from previous location ---",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldBe(stackTrace);
}

[Test]
public void OrphanedSeparator_AfterStrippedTUnitFrame_IsRemoved()
{
// The separator originally followed a TUnit frame; once stripped, the
// separator must not orphan at the top of the output.
var stackTrace = string.Join(Environment.NewLine,
" at TUnit.Assertions.Check()",
"--- End of stack trace from previous location ---",
" at MyApp.Tests.MyTest() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

var lines = result.Split(Environment.NewLine);
lines[0].ShouldNotContain("End of stack trace from previous location");
lines[0].ShouldContain("MyApp.Tests.MyTest");
}

[Test]
public void TrailingSeparator_BeforeOnlyStrippedFrames_IsRemoved()
{
// The separator originally pointed at a TUnit frame; once that frame
// is stripped, the separator must not dangle right before the hint.
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.Tests.MyTest() in C:\\src\\Tests.cs:line 15",
"--- End of stack trace from previous location ---",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldNotContain("End of stack trace from previous location");
result.ShouldContain("MyApp.Tests.MyTest");
result.ShouldContain("--detailed-stacktrace");
}

[Test]
public void Separator_BetweenStrippedTUnitAndUserFrame_IsRemoved()
{
// Separator's "before" anchor (the TUnit frame above) is gone after
// stripping, so it would misleadingly imply an async boundary between
// First() and Second() that wasn't really there in user code.
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.First() in C:\\src\\App.cs:line 10",
" at TUnit.Core.SomeHelper.DoSomething()",
"--- End of stack trace from previous location ---",
" at MyApp.Second() in C:\\src\\App.cs:line 20",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("MyApp.First");
result.ShouldContain("MyApp.Second");
result.ShouldNotContain("End of stack trace from previous location");
result.ShouldNotContain("TUnit.Core.SomeHelper");
result.ShouldNotContain("TUnit.Engine.TestExecutor");
}

[Test]
public void Separator_BetweenTwoUserFrames_IsPreserved()
{
// Both anchors are kept user frames — separator stays.
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.First() in C:\\src\\App.cs:line 10",
"--- End of stack trace from previous location ---",
" at MyApp.Second() in C:\\src\\App.cs:line 20",
" at TUnit.Engine.TestExecutor.ExecuteAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

result.ShouldContain("End of stack trace from previous location");
result.ShouldContain("MyApp.First");
result.ShouldContain("MyApp.Second");
}

[Test]
public void FilteredTrace_HintHasCorrectFormat()
{
var stackTrace = string.Join(Environment.NewLine,
" at MyApp.Tests.MyTest() in C:\\src\\Tests.cs:line 15",
" at TUnit.Core.RunHelpers.RunAsync()");

var result = TUnitFailedException.FilterStackTrace(stackTrace);

var lines = result.Split(Environment.NewLine);
lines.Length.ShouldBe(2);
lines[0].ShouldContain("MyApp.Tests.MyTest");
lines[1].ShouldContain("--detailed-stacktrace");
}
}
96 changes: 87 additions & 9 deletions TUnit.Engine/Exceptions/TUnitFailedException.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using TUnit.Core.Exceptions;
using TUnit.Core.Helpers;
using TUnit.Engine.CommandLineProviders;

namespace TUnit.Engine.Exceptions;

Expand All @@ -17,28 +18,105 @@

public override string StackTrace { get; }

private static string FilterStackTrace(string? stackTrace)
// The hint only mentions --detailed-stacktrace because filtering is bypassed
// entirely when --log-level Debug/Trace is set (see TUnitMessageBus.SimplifyStacktrace),
// so users on debug logging will never see this message.
private const string OmittedHint =
$" --- TUnit internals omitted (run with --{DetailedStacktraceCommandProvider.DetailedStackTrace} for full trace) ---";

internal static string FilterStackTrace(string? stackTrace)
{
if (string.IsNullOrEmpty(stackTrace))
{
return string.Empty;
}

var vsb = new ValueStringBuilder(stackalloc char[256]);
var span = stackTrace.AsSpan();

// Fast path: user-code failures usually throw from pure user frames with no
// TUnit internals at all — skip the split/copy loop entirely in that case.
if (span.IndexOf("at TUnit.".AsSpan()) < 0)
{
return stackTrace;

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Możliwe zwrócenie odwołania o wartości null.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Możliwe zwrócenie odwołania o wartości null.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Mögliche Nullverweisrückgabe.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Mögliche Nullverweisrückgabe.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Existence possible d'un retour de référence null.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference return.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference return.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference return.

Check failure on line 40 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference return.
}

var vsb = new ValueStringBuilder(stackalloc char[1024]);
var added = false;
foreach(var range in stackTrace.AsSpan().Split(Environment.NewLine))
var omittedAny = false;
var lastWasStripped = false;
Range pendingSeparator = default;

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

Check failure on line 47 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.
var hasPendingSeparator = false;

foreach (var range in span.Split(Environment.NewLine))
{
var slice = stackTrace.AsSpan()[range];
if (slice.Trim().StartsWith("at TUnit"))
var slice = span[range];
var trimmed = slice.Trim();

if (IsTUnitInternalFrame(trimmed))
{
omittedAny = true;
// A separator we were holding described resumption *into* the frame
// we're now stripping — its anchor is gone, so drop it.
hasPendingSeparator = false;
lastWasStripped = true;
continue;
}

var isFrame = trimmed.StartsWith("at ");
if (isFrame)
{
if (added)
{
vsb.Append(Environment.NewLine);
}

if (hasPendingSeparator)
{
vsb.Append(span[pendingSeparator]);
vsb.Append(Environment.NewLine);
hasPendingSeparator = false;
}

vsb.Append(slice);
added = true;
lastWasStripped = false;
continue;
}

// Separator/non-frame line: only emit if it sits between two surviving
// user frames. Drop it if nothing has been emitted yet (would orphan at
// the top), or if the previous non-separator line was a stripped TUnit
// frame (its "before" anchor is gone). Otherwise buffer — we'll flush
// it when (and only when) a surviving user frame follows.
if (!added || lastWasStripped)
{
break;
continue;
}
vsb.Append(added ? Environment.NewLine : "");
vsb.Append(slice);
added = true;

pendingSeparator = range;
hasPendingSeparator = true;
}

// If every frame is TUnit-internal, the error originates inside TUnit itself —
// return the full unfiltered trace so the bug can be diagnosed. Also return
// the original if nothing was actually omitted (the fast-path `at TUnit.` match
// landed inside a non-frame line, e.g. an embedded message).
if (!added || !omittedAny)
{
vsb.Dispose();
return stackTrace;

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Możliwe zwrócenie odwołania o wartości null.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Możliwe zwrócenie odwołania o wartości null.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Mögliche Nullverweisrückgabe.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Mögliche Nullverweisrückgabe.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Existence possible d'un retour de référence null.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference return.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference return.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference return.

Check failure on line 107 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference return.
}

vsb.Append(Environment.NewLine);
vsb.Append(OmittedHint);

return vsb.ToString();
}

private static bool IsTUnitInternalFrame(ReadOnlySpan<char> trimmedLine)
{
// Uses "at TUnit." with the dot to avoid false positives on user types whose names
// happen to start with "TUnit" (e.g. a hypothetical "TUnitExtensions" namespace).
return trimmedLine.StartsWith("at TUnit.");
}
}
Loading