diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index 08fdf590f..82bf92874 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -16,19 +16,16 @@ namespace Quantum\Tracer; -use Quantum\Storage\Contracts\LocalFilesystemAdapterInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Quantum\Renderer\Exceptions\RendererException; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; -use Quantum\View\Factories\ViewFactory; use Quantum\Di\Exceptions\DiException; +use Quantum\Http\Enums\StatusCode; use Quantum\Logger\Logger; use ReflectionException; -use Psr\Log\LogLevel; use ErrorException; -use ParseError; use Throwable; use Exception; @@ -38,32 +35,20 @@ */ class ErrorHandler { - /** - * Number of lines to be returned - */ - public const NUM_LINES = 10; - - /** - * @var array - */ - public const ERROR_TYPES = [ - E_ERROR => 'error', - E_WARNING => 'warning', - E_PARSE => 'error', - E_NOTICE => 'notice', - E_CORE_ERROR => 'error', - E_CORE_WARNING => 'warning', - E_COMPILE_ERROR => 'error', - E_COMPILE_WARNING => 'warning', - E_USER_ERROR => 'error', - E_USER_WARNING => 'warning', - E_USER_NOTICE => 'notice', - E_STRICT => 'notice', - E_RECOVERABLE_ERROR => 'error', - ]; - private ?Logger $logger = null; private ?OutputInterface $cliOutput = null; + private ExceptionSeverityResolver $severityResolver; + private WebExceptionRenderer $webExceptionRenderer; + + public function __construct( + ?ExceptionSeverityResolver $severityResolver = null, + ?StackTraceFormatter $stackTraceFormatter = null, + ?WebExceptionRenderer $webExceptionRenderer = null + ) { + $this->severityResolver = $severityResolver ?? new ExceptionSeverityResolver(); + $formatter = $stackTraceFormatter ?? new StackTraceFormatter(); + $this->webExceptionRenderer = $webExceptionRenderer ?? new WebExceptionRenderer($formatter); + } private function __clone() { @@ -110,30 +95,36 @@ public function handleException(Throwable $throwable): void private function handleCliException(Throwable $throwable): void { $output = $this->cliOutput ?? new ConsoleOutput(); - $output->writeln('' . $throwable->getMessage() . ''); + + if (!is_debug_mode()) { + $output->writeln('' . $throwable->getMessage() . ''); + return; + } + + $output->writeln('' . get_class($throwable) . ': ' . $throwable->getMessage() . ''); + $output->writeln('In ' . $throwable->getFile() . ':' . $throwable->getLine()); + $output->writeln($throwable->getTraceAsString()); } /** - * @throws ConfigException|RendererException|DiException|BaseException|ReflectionException|Exception + * @throws DiException|BaseException|ReflectionException|Exception */ private function handleWebException(Throwable $throwable): void { - $view = ViewFactory::get(); $errorType = $this->getErrorType($throwable); - if (is_debug_mode()) { - $errorPage = $view->renderPartial('errors' . DS . 'trace', [ - 'stackTrace' => $this->composeStackTrace($throwable), - 'errorMessage' => $throwable->getMessage(), - 'severity' => ucfirst($errorType), - ]); - } else { - $errorPage = $view->renderPartial('errors' . DS . '500'); + if (!is_debug_mode()) { $this->logError($throwable, $errorType); } - response()->html($errorPage); - response()->send(); + try { + $errorPage = $this->webExceptionRenderer->render($throwable, $errorType); + response()->html($errorPage, StatusCode::INTERNAL_SERVER_ERROR); + response()->send(); + } catch (Throwable $e) { + response()->html('Internal Server Error', StatusCode::INTERNAL_SERVER_ERROR); + response()->send(); + } } private function logError(Throwable $e, string $errorType): void @@ -147,95 +138,11 @@ private function logError(Throwable $e, string $errorType): void $this->logger->$logMethod($e->getMessage(), ['trace' => $e->getTraceAsString()]); } - /** - * Composes the stack trace - * @return array - * @throws ConfigException|RendererException|DiException|BaseException|ReflectionException - */ - private function composeStackTrace(Throwable $e): array - { - $trace[] = [ - 'file' => $e->getFile(), - 'code' => $this->getSourceCode($e->getFile(), $e->getLine(), 'error-line'), - ]; - - foreach ($e->getTrace() as $item) { - if (($item['class'] ?? null) === self::class) { - continue; - } - - if (isset($item['file'])) { - $trace[] = [ - 'file' => $item['file'], - 'code' => $this->getSourceCode($item['file'], $item['line'] ?? 1, 'switch-line'), - ]; - } - } - - return $trace; - } - - /** - * Gets the source code where the error happens - * @throws ConfigException|RendererException|DiException|BaseException|ReflectionException - */ - private function getSourceCode(string $filename, int $lineNumber, string $className): string - { - $lineNumber--; - - $halfLines = intdiv(self::NUM_LINES, 2); - $start = max($lineNumber - $halfLines, 1); - - $adapter = fs()->getAdapter(); - - if (!$adapter instanceof LocalFilesystemAdapterInterface) { - return ''; - } - - $lines = $adapter->getLines($filename, $start, self::NUM_LINES); - - $code = '
    '; - - foreach ($lines as $currentLineNumber => $line) { - $code .= $this->formatLineItem($currentLineNumber, $line, $lineNumber, $className); - } - - return $code . '
'; - } - - /** - * Formats the line item - */ - private function formatLineItem(int $currentLineNumber, string $line, int $lineNumber, string $className): string - { - $highlightClass = $currentLineNumber === $lineNumber ? " class=\"{$className}\"" : ''; - - $encodedLine = htmlspecialchars($line, ENT_QUOTES); - - return sprintf( - '
%s
', - $highlightClass, - $encodedLine - ); - } - /** * Gets the error type based on the exception class */ private function getErrorType(Throwable $e): string { - if ($e instanceof ErrorException) { - return self::ERROR_TYPES[$e->getSeverity()] ?? LogLevel::ERROR; - } - - if ($e instanceof ParseError) { - return LogLevel::CRITICAL; - } - - if ($e instanceof ReflectionException) { - return LogLevel::WARNING; - } - - return LogLevel::ERROR; + return $this->severityResolver->resolve($e); } } diff --git a/src/Tracer/ExceptionSeverityResolver.php b/src/Tracer/ExceptionSeverityResolver.php new file mode 100644 index 000000000..1090272b0 --- /dev/null +++ b/src/Tracer/ExceptionSeverityResolver.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Tracer; + +use ReflectionException; +use Psr\Log\LogLevel; +use ErrorException; +use ParseError; +use Throwable; + +final class ExceptionSeverityResolver +{ + /** + * @var array + */ + private const ERROR_TYPES = [ + E_ERROR => 'error', + E_WARNING => 'warning', + E_PARSE => 'error', + E_NOTICE => 'notice', + E_CORE_ERROR => 'error', + E_CORE_WARNING => 'warning', + E_COMPILE_ERROR => 'error', + E_COMPILE_WARNING => 'warning', + E_USER_ERROR => 'error', + E_USER_WARNING => 'warning', + E_USER_NOTICE => 'notice', + E_STRICT => 'notice', + E_RECOVERABLE_ERROR => 'error', + ]; + + public function resolve(Throwable $throwable): string + { + if ($throwable instanceof ErrorException) { + return self::ERROR_TYPES[$throwable->getSeverity()] ?? LogLevel::ERROR; + } + + if ($throwable instanceof ParseError) { + return LogLevel::CRITICAL; + } + + if ($throwable instanceof ReflectionException) { + return LogLevel::WARNING; + } + + return LogLevel::ERROR; + } +} diff --git a/src/Tracer/StackTraceFormatter.php b/src/Tracer/StackTraceFormatter.php new file mode 100644 index 000000000..e142e17c1 --- /dev/null +++ b/src/Tracer/StackTraceFormatter.php @@ -0,0 +1,83 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Tracer; + +use Quantum\Storage\Contracts\LocalFilesystemAdapterInterface; +use Throwable; + +final class StackTraceFormatter +{ + private const NUM_LINES = 10; + + /** + * @return array + */ + public function compose(Throwable $throwable): array + { + $trace[] = [ + 'file' => $throwable->getFile(), + 'code' => $this->getSourceCode($throwable->getFile(), $throwable->getLine(), 'error-line'), + ]; + + foreach ($throwable->getTrace() as $item) { + if (($item['class'] ?? null) === ErrorHandler::class) { + continue; + } + + if (isset($item['file'])) { + $trace[] = [ + 'file' => $item['file'], + 'code' => $this->getSourceCode($item['file'], $item['line'] ?? 1, 'switch-line'), + ]; + } + } + + return $trace; + } + + public function getSourceCode(string $filename, int $lineNumber, string $className): string + { + $lineNumber--; + + $halfLines = intdiv(self::NUM_LINES, 2); + $start = max($lineNumber - $halfLines, 1); + + $adapter = fs()->getAdapter(); + + if (!$adapter instanceof LocalFilesystemAdapterInterface) { + return ''; + } + + $lines = $adapter->getLines($filename, $start, self::NUM_LINES); + + $code = '
    '; + + foreach ($lines as $currentLineNumber => $line) { + $code .= $this->formatLineItem($currentLineNumber, $line, $lineNumber, $className); + } + + return $code . '
'; + } + + public function formatLineItem(int $currentLineNumber, string $line, int $lineNumber, string $className): string + { + $highlightClass = $currentLineNumber === $lineNumber ? " class=\"{$className}\"" : ''; + $encodedLine = htmlspecialchars($line, ENT_QUOTES); + + return sprintf('
%s
', $highlightClass, $encodedLine); + } +} diff --git a/src/Tracer/WebExceptionRenderer.php b/src/Tracer/WebExceptionRenderer.php new file mode 100644 index 000000000..e04f2c026 --- /dev/null +++ b/src/Tracer/WebExceptionRenderer.php @@ -0,0 +1,50 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Tracer; + +use Quantum\Renderer\Exceptions\RendererException; +use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; +use ReflectionException; +use Throwable; + +class WebExceptionRenderer +{ + private StackTraceFormatter $stackTraceFormatter; + + public function __construct(?StackTraceFormatter $stackTraceFormatter = null) + { + $this->stackTraceFormatter = $stackTraceFormatter ?? new StackTraceFormatter(); + } + + /** + * @throws ConfigException|RendererException|DiException|BaseException|ReflectionException + */ + public function render(Throwable $throwable, string $errorType): string + { + if (is_debug_mode()) { + return view()->renderPartial('errors' . DS . 'trace', [ + 'stackTrace' => $this->stackTraceFormatter->compose($throwable), + 'errorMessage' => $throwable->getMessage(), + 'severity' => ucfirst($errorType), + ]); + } + + return view()->renderPartial('errors' . DS . '500'); + } +} diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index 0d4cc8201..40f9f4b61 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -15,6 +15,7 @@ use Quantum\Router\Route; use Quantum\Http\Response; use ReflectionClass; +use ReflectionMethod; use Quantum\App\App; use Quantum\Di\Di; use ReflectionProperty; @@ -92,6 +93,14 @@ protected function getPrivateProperty($object, $property) return $property->getValue($object); } + protected function invokePrivateMethod(object $object, string $method, array $args = []) + { + $reflectionMethod = new ReflectionMethod($object, $method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($object, $args); + } + protected function createFile(string $filePath, string $content) { $this->fs->put($filePath, $content); diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index bd03fe966..3c12df59a 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -2,16 +2,12 @@ namespace Quantum\Tests\Unit\Tracer; -use Quantum\Storage\Factories\FileSystemFactory; -use Quantum\Storage\Contracts\FilesystemAdapterInterface; -use Quantum\Storage\FileSystem; +use Symfony\Component\Console\Output\BufferedOutput; +use Quantum\Tracer\WebExceptionRenderer; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; -use Quantum\Di\Di; use Quantum\Logger\Logger; -use Symfony\Component\Console\Output\BufferedOutput; use ReflectionException; -use ReflectionMethod; use ErrorException; use ParseError; use Exception; @@ -94,16 +90,10 @@ public function testHandleErrorReturnsFalseForSuppressedErrors(): void } } - public function testErrorTypesConstant(): void - { - $this->assertEquals('error', ErrorHandler::ERROR_TYPES[E_ERROR]); - $this->assertEquals('warning', ErrorHandler::ERROR_TYPES[E_WARNING]); - $this->assertEquals('notice', ErrorHandler::ERROR_TYPES[E_NOTICE]); - $this->assertEquals('error', ErrorHandler::ERROR_TYPES[E_PARSE]); - } - public function testHandleExceptionCliWritesExceptionMessage(): void { + config()->set('app.debug', false); + $output = new BufferedOutput(); $this->errorHandler->setCliOutput($output); @@ -112,6 +102,21 @@ public function testHandleExceptionCliWritesExceptionMessage(): void $this->assertStringContainsString('CLI failure', $output->fetch()); } + public function testHandleExceptionCliInDebugModeWritesVerboseDiagnostics(): void + { + config()->set('app.debug', true); + + $output = new BufferedOutput(); + $this->errorHandler->setCliOutput($output); + + $this->errorHandler->handleException(new Exception('CLI debug failure')); + + $display = $output->fetch(); + $this->assertStringContainsString(Exception::class . ': CLI debug failure', $display); + $this->assertStringContainsString('In ', $display); + $this->assertStringContainsString(__FILE__, $display); + } + public function testHandleWebExceptionInDebugModeRendersTraceView(): void { config()->set('app.debug', true); @@ -119,7 +124,7 @@ public function testHandleWebExceptionInDebugModeRendersTraceView(): void $exception = new Exception('Web debug failure'); ob_start(); - $this->invokePrivateMethod('handleWebException', [$exception]); + $this->invokePrivateMethod($this->errorHandler, 'handleWebException', [$exception]); ob_get_clean(); $content = response()->getContent(); @@ -143,18 +148,38 @@ public function testHandleWebExceptionInProductionModeLogsAndRenders500(): void $exception = new ErrorException('Web production warning', 0, E_WARNING, __FILE__, __LINE__); ob_start(); - $this->invokePrivateMethod('handleWebException', [$exception]); + $this->invokePrivateMethod($this->errorHandler, 'handleWebException', [$exception]); ob_get_clean(); $content = response()->getContent(); $this->assertStringContainsString('ERROR 500 VIEW', $content); } + public function testHandleWebExceptionFallsBackWhenRendererThrows(): void + { + config()->set('app.debug', true); + + $renderer = Mockery::mock(WebExceptionRenderer::class); + $renderer->shouldReceive('render') + ->once() + ->andThrow(new Exception('render failed')); + + $handler = new ErrorHandler(null, null, $renderer); + + ob_start(); + $this->invokePrivateMethod($handler, 'handleWebException', [new Exception('boom')]); + ob_get_clean(); + + $this->assertSame('Internal Server Error', response()->getContent()); + $this->assertSame(500, response()->getStatusCode()); + $this->assertStringStartsWith('text/html', (string) response()->getHeader('Content-Type')); + } + public function testLogErrorReturnsEarlyWhenLoggerIsNull(): void { $this->setPrivateProperty($this->errorHandler, 'logger', null); - $this->invokePrivateMethod('logError', [new Exception('No logger'), 'error']); + $this->invokePrivateMethod($this->errorHandler, 'logError', [new Exception('No logger'), 'error']); $this->assertTrue(true); } @@ -168,156 +193,17 @@ public function testLogErrorFallsBackToErrorMethodForUnknownLevel(): void $this->setPrivateProperty($this->errorHandler, 'logger', $logger); - $this->invokePrivateMethod('logError', [new Exception('Unknown level message'), 'not_existing_level']); + $this->invokePrivateMethod($this->errorHandler, 'logError', [new Exception('Unknown level message'), 'not_existing_level']); $this->assertTrue(true); } - public function testComposeStackTraceSkipsHandlerClassEntriesAndBuildsCodeSnippets(): void - { - $sourceFile = PROJECT_ROOT . DS . 'shared' . DS . 'trace-source.php'; - $this->createFile($sourceFile, "setAccessible(true); - $throwableProperty->setValue($throwable, $sourceFile); - - $lineProperty = new \ReflectionProperty(Exception::class, 'line'); - $lineProperty->setAccessible(true); - $lineProperty->setValue($throwable, 3); - - $traceProperty = new \ReflectionProperty(Exception::class, 'trace'); - $traceProperty->setAccessible(true); - $traceProperty->setValue($throwable, [ - ['class' => ErrorHandler::class, 'file' => $sourceFile, 'line' => 2], - ['class' => 'ExternalClass', 'file' => $sourceFile, 'line' => 4], - ['class' => 'NoFileClass'], - ]); - - $trace = $this->invokePrivateMethod('composeStackTrace', [$throwable]); - - $this->assertCount(2, $trace); - $this->assertSame($sourceFile, $trace[0]['file']); - $this->assertStringContainsString('
    ', $trace[0]['code']); - $this->assertStringContainsString('class="error-line"', $trace[0]['code']); - $this->assertSame($sourceFile, $trace[1]['file']); - $this->assertStringContainsString('class="switch-line"', $trace[1]['code']); - } finally { - $this->removeFile($sourceFile); - } - } - - public function testGetSourceCodeReturnsEmptyStringForNonLocalAdapter(): void - { - $adapter = new class () implements FilesystemAdapterInterface { - public function makeDirectory(string $dirname, ?string $parentId = null): bool - { - return false; - } - public function removeDirectory(string $dirname): bool - { - return false; - } - public function get(string $filename) - { - return false; - } - public function put(string $filename, $content, ?string $parentId = null) - { - return false; - } - public function append(string $filename, $content) - { - return false; - } - public function rename(string $oldName, string $newName): bool - { - return false; - } - public function copy(string $source, string $dest): bool - { - return false; - } - public function exists(string $filename): bool - { - return false; - } - public function size(string $filename) - { - return false; - } - public function lastModified(string $filename) - { - return false; - } - public function remove(string $filename): bool - { - return false; - } - public function isFile(string $filename): bool - { - return false; - } - public function isDirectory(string $dirname): bool - { - return false; - } - public function listDirectory(string $dirname) - { - return false; - } - }; - - $factory = Di::get(FileSystemFactory::class); - $instancesProperty = new \ReflectionProperty(FileSystemFactory::class, 'instances'); - $instancesProperty->setAccessible(true); - $originalInstances = $instancesProperty->getValue($factory); - - try { - $instancesProperty->setValue($factory, ['local' => new FileSystem($adapter)]); - - $code = $this->invokePrivateMethod('getSourceCode', [__FILE__, 10, 'error-line']); - - $this->assertSame('', $code); - } finally { - $instancesProperty->setValue($factory, $originalInstances); - } - } - - public function testGetSourceCodeBuildsOrderedListForLocalAdapter(): void - { - $file = PROJECT_ROOT . DS . 'shared' . DS . 'source-code.php'; - $this->createFile($file, "invokePrivateMethod('getSourceCode', [$file, 5, 'error-line']); - - $this->assertStringContainsString('
      ', $code); - $this->assertStringContainsString('class="error-line"', $code); - $this->assertStringContainsString('
      C
      ', $code); - } finally { - $this->removeFile($file); - } - } - - public function testFormatLineItemEscapesHtmlAndAppliesHighlightClass(): void - { - $html = $this->invokePrivateMethod('formatLineItem', [5, '
      A&B
      ', 5, 'switch-line']); - $plain = $this->invokePrivateMethod('formatLineItem', [4, 'plain', 5, 'switch-line']); - - $this->assertStringContainsString('class="switch-line"', $html); - $this->assertStringContainsString('<div class="x">A&B</div>', $html); - $this->assertStringNotContainsString('class="switch-line"', $plain); - } - /** * @dataProvider errorTypeProvider */ public function testGetErrorTypeMappings(\Throwable $throwable, string $expected): void { - $type = $this->invokePrivateMethod('getErrorType', [$throwable]); + $type = $this->invokePrivateMethod($this->errorHandler, 'getErrorType', [$throwable]); $this->assertSame($expected, $type); } @@ -336,15 +222,4 @@ public function errorTypeProvider(): array ]; } - /** - * @param array $args - * @return mixed - */ - private function invokePrivateMethod(string $method, array $args = []) - { - $reflectionMethod = new ReflectionMethod($this->errorHandler, $method); - $reflectionMethod->setAccessible(true); - - return $reflectionMethod->invokeArgs($this->errorHandler, $args); - } } diff --git a/tests/Unit/Tracer/ExceptionSeverityResolverTest.php b/tests/Unit/Tracer/ExceptionSeverityResolverTest.php new file mode 100644 index 000000000..c60981885 --- /dev/null +++ b/tests/Unit/Tracer/ExceptionSeverityResolverTest.php @@ -0,0 +1,42 @@ +resolver = new ExceptionSeverityResolver(); + } + + /** + * @dataProvider mappingProvider + */ + public function testResolveMappings(\Throwable $throwable, string $expected): void + { + $this->assertSame($expected, $this->resolver->resolve($throwable)); + } + + /** + * @return array + */ + public function mappingProvider(): array + { + return [ + 'mapped-error-exception' => [new ErrorException('x', 0, E_WARNING), 'warning'], + 'unmapped-error-exception' => [new ErrorException('x', 0, E_USER_DEPRECATED), 'error'], + 'parse-error' => [new ParseError('bad syntax'), 'critical'], + 'reflection-exception' => [new ReflectionException('reflection failed'), 'warning'], + 'generic-exception' => [new Exception('unknown'), 'error'], + ]; + } +} diff --git a/tests/Unit/Tracer/StackTraceFormatterTest.php b/tests/Unit/Tracer/StackTraceFormatterTest.php new file mode 100644 index 000000000..4b6af87f5 --- /dev/null +++ b/tests/Unit/Tracer/StackTraceFormatterTest.php @@ -0,0 +1,74 @@ +formatter = new StackTraceFormatter(); + } + + public function testComposeSkipsErrorHandlerClassEntriesAndBuildsCodeSnippets(): void + { + $sourceFile = PROJECT_ROOT . DS . 'shared' . DS . 'trace-source-formatter.php'; + $this->createFile($sourceFile, "setPrivateProperty($throwable, 'file', $sourceFile); + $this->setPrivateProperty($throwable, 'line', 3); + $this->setPrivateProperty($throwable, 'trace', [ + ['class' => ErrorHandler::class, 'file' => $sourceFile, 'line' => 2], + ['class' => 'ExternalClass', 'file' => $sourceFile, 'line' => 4], + ['class' => 'NoFileClass'], + ]); + + $trace = $this->formatter->compose($throwable); + + $this->assertCount(2, $trace); + $this->assertSame($sourceFile, $trace[0]['file']); + $this->assertStringContainsString('
        ', $trace[0]['code']); + $this->assertStringContainsString('class="error-line"', $trace[0]['code']); + $this->assertSame($sourceFile, $trace[1]['file']); + $this->assertStringContainsString('class="switch-line"', $trace[1]['code']); + } finally { + $this->removeFile($sourceFile); + } + } + + public function testGetSourceCodeReturnsEmptyStringForNonLocalAdapter(): void + { + $adapter = Mockery::mock(FilesystemAdapterInterface::class); + + $factory = Di::get(FileSystemFactory::class); + $instancesProperty = new ReflectionProperty(FileSystemFactory::class, 'instances'); + $instancesProperty->setAccessible(true); + $originalInstances = $instancesProperty->getValue($factory); + + try { + $instancesProperty->setValue($factory, ['local' => new FileSystem($adapter)]); + + $code = $this->formatter->getSourceCode(__FILE__, 10, 'error-line'); + + $this->assertSame('', $code); + } finally { + $instancesProperty->setValue($factory, $originalInstances); + } + } +} diff --git a/tests/Unit/Tracer/WebExceptionRendererTest.php b/tests/Unit/Tracer/WebExceptionRendererTest.php new file mode 100644 index 000000000..d19f1d4f5 --- /dev/null +++ b/tests/Unit/Tracer/WebExceptionRendererTest.php @@ -0,0 +1,31 @@ +set('app.debug', true); + + $renderer = new WebExceptionRenderer(); + $html = $renderer->render(new Exception('Debug renderer failure'), 'warning'); + + $this->assertStringContainsString('TRACE VIEW', $html); + $this->assertStringContainsString('Warning :: Debug renderer failure', $html); + } + + public function testRenderProductionModeUses500Template(): void + { + config()->set('app.debug', false); + + $renderer = new WebExceptionRenderer(); + $html = $renderer->render(new Exception('Prod renderer failure'), 'error'); + + $this->assertStringContainsString('ERROR 500 VIEW', $html); + } +}