Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
180 changes: 180 additions & 0 deletions TUnit.Engine.Tests/StackTraceFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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 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");
}
}
68 changes: 61 additions & 7 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,81 @@

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 (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 (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 (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 (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 (ubuntu-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;

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;
continue;
}

// Skip separator/non-frame lines until we've emitted a frame, otherwise
// an "End of stack trace from previous location" line that originally
// followed a stripped TUnit frame would orphan at the top of the output.
var isFrame = trimmed.StartsWith("at ");
if (!added && !isFrame)
{
continue;
}

if (added)
{
break;
vsb.Append(Environment.NewLine);
}
vsb.Append(added ? Environment.NewLine : "");

vsb.Append(slice);
added = 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 83 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 83 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 83 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 83 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 83 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 83 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 83 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 83 in TUnit.Engine/Exceptions/TUnitFailedException.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-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