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
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");
}
}
104 changes: 94 additions & 10 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,111 @@ protected TUnitFailedException(string? message, Exception? innerException) : bas

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))
// Pattern check (rather than `string.IsNullOrEmpty`) so the compiler narrows
// `stackTrace` to non-null in the rest of the method on netstandard2.0,
// where `IsNullOrEmpty` lacks `[NotNullWhen(false)]`.
if (stackTrace is null || stackTrace.Length == 0)
{
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;
}

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;
// ReadOnlySpan rather than Range to avoid a nullability warning from the
// netstandard2.0 polyfill — Range is a value type but the polyfill annotates
// `default` as a possible null assignment.
ReadOnlySpan<char> pendingSeparator = default;
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(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 = slice;
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;
}

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