diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index 5c4ed3b85..08fdf590f 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -17,13 +17,13 @@ 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 DebugBar\DebugBarException; use Quantum\Logger\Logger; use ReflectionException; use Psr\Log\LogLevel; @@ -63,6 +63,7 @@ class ErrorHandler ]; private ?Logger $logger = null; + private ?OutputInterface $cliOutput = null; private function __clone() { @@ -77,6 +78,11 @@ public function setup(Logger $logger): void set_exception_handler([$this, 'handleException']); } + public function setCliOutput(OutputInterface $output): void + { + $this->cliOutput = $output; + } + /** * @throws ErrorException */ @@ -90,7 +96,7 @@ public function handleError(int $severity, string $message, string $file, int $l } /** - * @throws ConfigException|RendererException|DebugBarException|DiException|BaseException|ReflectionException + * @throws ConfigException|RendererException|DiException|BaseException|ReflectionException */ public function handleException(Throwable $throwable): void { @@ -103,7 +109,7 @@ public function handleException(Throwable $throwable): void private function handleCliException(Throwable $throwable): void { - $output = new ConsoleOutput(); + $output = $this->cliOutput ?? new ConsoleOutput(); $output->writeln('' . $throwable->getMessage() . ''); } diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index deafb5055..bd03fe966 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -2,10 +2,19 @@ namespace Quantum\Tests\Unit\Tracer; +use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Storage\Contracts\FilesystemAdapterInterface; +use Quantum\Storage\FileSystem; 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; use Mockery; class ErrorHandlerTest extends AppTestCase @@ -92,4 +101,250 @@ public function testErrorTypesConstant(): void $this->assertEquals('notice', ErrorHandler::ERROR_TYPES[E_NOTICE]); $this->assertEquals('error', ErrorHandler::ERROR_TYPES[E_PARSE]); } + + public function testHandleExceptionCliWritesExceptionMessage(): void + { + $output = new BufferedOutput(); + $this->errorHandler->setCliOutput($output); + + $this->errorHandler->handleException(new Exception('CLI failure')); + + $this->assertStringContainsString('CLI failure', $output->fetch()); + } + + public function testHandleWebExceptionInDebugModeRendersTraceView(): void + { + config()->set('app.debug', true); + + $exception = new Exception('Web debug failure'); + + ob_start(); + $this->invokePrivateMethod('handleWebException', [$exception]); + ob_get_clean(); + + $content = response()->getContent(); + $this->assertStringContainsString('TRACE VIEW', $content); + $this->assertStringContainsString('Error :: Web debug failure', $content); + } + + public function testHandleWebExceptionInProductionModeLogsAndRenders500(): void + { + config()->set('app.debug', false); + + $logger = Mockery::mock(Logger::class); + $logger->shouldReceive('warning') + ->once() + ->with('Web production warning', Mockery::on(function (array $context): bool { + return array_key_exists('trace', $context) && is_string($context['trace']); + })); + + $this->setPrivateProperty($this->errorHandler, 'logger', $logger); + + $exception = new ErrorException('Web production warning', 0, E_WARNING, __FILE__, __LINE__); + + ob_start(); + $this->invokePrivateMethod('handleWebException', [$exception]); + ob_get_clean(); + + $content = response()->getContent(); + $this->assertStringContainsString('ERROR 500 VIEW', $content); + } + + public function testLogErrorReturnsEarlyWhenLoggerIsNull(): void + { + $this->setPrivateProperty($this->errorHandler, 'logger', null); + + $this->invokePrivateMethod('logError', [new Exception('No logger'), 'error']); + + $this->assertTrue(true); + } + + public function testLogErrorFallsBackToErrorMethodForUnknownLevel(): void + { + $logger = Mockery::mock(Logger::class); + $logger->shouldReceive('error') + ->once() + ->with('Unknown level message', Mockery::type('array')); + + $this->setPrivateProperty($this->errorHandler, 'logger', $logger); + + $this->invokePrivateMethod('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]); + + $this->assertSame($expected, $type); + } + + /** + * @return array + */ + public function errorTypeProvider(): 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'], + ]; + } + + /** + * @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/_root/shared/views/errors/500.php b/tests/_root/shared/views/errors/500.php new file mode 100644 index 000000000..72bcdeaa8 --- /dev/null +++ b/tests/_root/shared/views/errors/500.php @@ -0,0 +1,16 @@ + + + + + Error Page 500 + + + + +
      +

      500

      +

      ERROR 500 VIEW

      +
      + + + diff --git a/tests/_root/shared/views/errors/trace.php b/tests/_root/shared/views/errors/trace.php new file mode 100644 index 000000000..d962d69d7 --- /dev/null +++ b/tests/_root/shared/views/errors/trace.php @@ -0,0 +1,17 @@ + + + + + Error Trace + + + + +
      +

      TRACE VIEW

      +

      ::

      +
      +
      + + +