Skip to content

Commit 55d94ea

Browse files
committed
feat(render): Add stack trace filtering for cleaner error output
Implement smart stack trace filtering to hide framework internals and show only relevant code: Helper changes: - Add filterStackTrace() method for filtering and formatting traces - Add cutTraceAtTestMethod() to cut trace at test method boundary - Add filterFrameworkFrames() to hide internal framework calls - Add formatFrame() for consistent frame formatting - Update formatThrowable() with new parameters: - testMethod: cuts trace at test method - maxPreviousDepth: shows previous exception chain (default 0) - compact: hides framework internals (default true) - Add FRAMEWORK_NAMESPACES constant for exclusion patterns TerminalLogger changes: - Pass test method to formatThrowable() in printFailures() - Use maxPreviousDepth=1 to show one level of previous exceptions - Enable compact mode for cleaner output Result: - Stack traces now show only relevant frames (test code, app code) - Framework internals hidden with message "... N internal framework calls hidden ..." - Support for previous exception chain with "Caused by:" prefix - Much cleaner and more readable error output
1 parent 1b65bc2 commit 55d94ea

File tree

2 files changed

+122
-8
lines changed

2 files changed

+122
-8
lines changed

src/Render/Helper.php

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,123 @@ final class Helper
1313
{
1414
/**
1515
* Formats a throwable into a detailed string with class, message, file, line, and stack trace.
16+
*
17+
* @param \Throwable $throwable The exception to format
18+
* @param \ReflectionMethod|\ReflectionFunction|null $testMethod Test method to cut stack trace at
19+
* @param int<0, max> $maxPreviousDepth Maximum depth for previous exceptions (0 = only main exception)
1620
*/
17-
public static function formatThrowable(\Throwable $throwable): string
21+
public static function formatThrowable(
22+
\Throwable $throwable,
23+
\ReflectionMethod|\ReflectionFunction|null $testMethod = null,
24+
int $maxPreviousDepth = 0,
25+
): string {
26+
$output = [];
27+
$depth = 0;
28+
$current = $throwable;
29+
30+
while ($current !== null && $depth <= $maxPreviousDepth) {
31+
// Exception header
32+
$exceptionClass = $current::class;
33+
if ($depth > 0) {
34+
$output[] = "\nCaused by: {$exceptionClass}: {$current->getMessage()}";
35+
} else {
36+
$output[] = "{$exceptionClass}: {$current->getMessage()}";
37+
}
38+
39+
$output[] = "File: {$current->getFile()}:{$current->getLine()}";
40+
41+
// Format stack trace
42+
$trace = self::filterStackTrace(
43+
$current->getTrace(),
44+
$testMethod,
45+
);
46+
47+
$output[] = "\nStack trace:\n{$trace}";
48+
49+
$current = $current->getPrevious();
50+
$depth++;
51+
}
52+
53+
return \implode("\n", $output);
54+
}
55+
56+
/**
57+
* Filters and formats a stack trace array.
58+
*
59+
* Cuts trace at test method, keeping only frames before the test method call.
60+
*
61+
* @param list<array<string, mixed>> $trace Raw stack trace from Throwable::getTrace()
62+
* @param \ReflectionMethod|\ReflectionFunction|null $testMethod Test method to cut trace at
63+
* @return non-empty-string Formatted stack trace
64+
*/
65+
public static function filterStackTrace(
66+
array $trace,
67+
\ReflectionMethod|\ReflectionFunction|null $testMethod = null,
68+
): string {
69+
// Cut trace at test method if provided
70+
if ($testMethod !== null) {
71+
$trace = self::cutTraceAtTestMethod($trace, $testMethod);
72+
}
73+
74+
// Format frames
75+
$lines = [];
76+
foreach ($trace as $i => $frame) {
77+
$lines[] = self::formatFrame($i, $frame);
78+
}
79+
80+
return $lines !== [] ? \implode("\n", $lines) : 'No stack trace available';
81+
}
82+
83+
/**
84+
* Cuts trace at test method, keeping only frames before the test method call.
85+
*
86+
* @param list<array<string, mixed>> $trace
87+
* @param \ReflectionMethod|\ReflectionFunction $testMethod
88+
* @return list<array<string, mixed>>
89+
*/
90+
private static function cutTraceAtTestMethod(
91+
array $trace,
92+
\ReflectionMethod|\ReflectionFunction $testMethod,
93+
): array {
94+
$testClass = $testMethod instanceof \ReflectionMethod
95+
? $testMethod->getDeclaringClass()->getName()
96+
: null;
97+
$testFunction = $testMethod->getName();
98+
99+
foreach ($trace as $i => $frame) {
100+
$isMatch = match (true) {
101+
$testClass !== null => ($frame['class'] ?? null) === $testClass
102+
&& ($frame['function'] ?? null) === $testFunction,
103+
default => ($frame['function'] ?? null) === $testFunction,
104+
};
105+
106+
if ($isMatch) {
107+
// Return only frames before this one (exclude the test method itself)
108+
return \array_slice($trace, 0, $i);
109+
}
110+
}
111+
112+
// Test method not found, return full trace
113+
return $trace;
114+
}
115+
116+
/**
117+
* Formats a single stack trace frame.
118+
*
119+
* @param int<0, max> $index
120+
* @param array<string, mixed> $frame
121+
*/
122+
private static function formatFrame(int $index, array $frame): string
18123
{
19-
$class = $throwable::class;
20-
$message = $throwable->getMessage();
21-
$file = $throwable->getFile();
22-
$line = $throwable->getLine();
23-
$trace = $throwable->getTraceAsString();
124+
$file = $frame['file'] ?? '[internal function]';
125+
$line = $frame['line'] ?? 0;
126+
$class = $frame['class'] ?? '';
127+
$type = $frame['type'] ?? '';
128+
$function = $frame['function'] ?? '';
129+
130+
$location = $line > 0 ? "{$file}:{$line}" : $file;
131+
$call = $class !== '' ? "{$class}{$type}{$function}()" : "{$function}()";
24132

25-
return "{$class}: {$message}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}";
133+
return "#{$index} {$location}\n {$call}";
26134
}
27135
}

src/Render/Terminal/TerminalLogger.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,13 @@ private function printFailures(): void
317317
$throwable = $result->failure;
318318

319319
$message = $throwable?->getMessage() ?? 'Test failed';
320-
$details = $throwable !== null ? Helper::formatThrowable($throwable) : '';
320+
$details = $throwable !== null
321+
? Helper::formatThrowable(
322+
$throwable,
323+
testMethod: $result->info->testDefinition->reflection,
324+
maxPreviousDepth: 1,
325+
)
326+
: '';
321327

322328
echo Formatter::failureDetail(
323329
$index,

0 commit comments

Comments
 (0)