From feb0bf999c0acf75151b36aa7873d616ae1ebefa Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:36:36 +0400 Subject: [PATCH 1/5] [#483] Increase Tracer ErrorHandler unit-test coverage --- tests/Unit/Tracer/ErrorHandlerTest.php | 249 +++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index deafb5055..4b2bd1328 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -2,25 +2,42 @@ 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 ReflectionException; +use ReflectionMethod; use ErrorException; +use ParseError; +use Exception; use Mockery; class ErrorHandlerTest extends AppTestCase { private ErrorHandler $errorHandler; + private string $traceViewPath; + private string $error500ViewPath; public function setUp(): void { parent::setUp(); $this->errorHandler = new ErrorHandler(); + $this->traceViewPath = PROJECT_ROOT . DS . 'shared' . DS . 'views' . DS . 'errors' . DS . 'trace.php'; + $this->error500ViewPath = PROJECT_ROOT . DS . 'shared' . DS . 'views' . DS . 'errors' . DS . '500.php'; + + $this->createFile($this->traceViewPath, '
TRACE VIEW :: ::
'); + $this->createFile($this->error500ViewPath, '
ERROR 500 VIEW
'); } public function tearDown(): void { + $this->removeFile($this->traceViewPath); + $this->removeFile($this->error500ViewPath); restore_error_handler(); restore_exception_handler(); parent::tearDown(); @@ -92,4 +109,236 @@ public function testErrorTypesConstant(): void $this->assertEquals('notice', ErrorHandler::ERROR_TYPES[E_NOTICE]); $this->assertEquals('error', ErrorHandler::ERROR_TYPES[E_PARSE]); } + + public function testHandleExceptionCliWritesExceptionMessage(): void + { + $this->expectNotToPerformAssertions(); + $this->errorHandler->handleException(new Exception('CLI failure')); + } + + 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->removeFile($sourceFile); + + $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']); + } + + 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); + $instancesProperty->setValue($factory, ['local' => new FileSystem($adapter)]); + + $code = $this->invokePrivateMethod('getSourceCode', [__FILE__, 10, 'error-line']); + + $this->assertSame('', $code); + } + + public function testGetSourceCodeBuildsOrderedListForLocalAdapter(): void + { + $file = PROJECT_ROOT . DS . 'shared' . DS . 'source-code.php'; + $this->createFile($file, "invokePrivateMethod('getSourceCode', [$file, 5, 'error-line']); + + $this->removeFile($file); + + $this->assertStringContainsString('
      ', $code); + $this->assertStringContainsString('class="error-line"', $code); + $this->assertStringContainsString('
      C
      ', $code); + } + + 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); + } } From 7a4e80e3d9da1f85c4ccf64f523bafbe821b3164 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:05:26 +0400 Subject: [PATCH 2/5] [#483] Add permanent tracer error view fixtures for tests --- tests/Unit/Tracer/ErrorHandlerTest.php | 9 --------- tests/_root/shared/views/errors/500.php | 16 ++++++++++++++++ tests/_root/shared/views/errors/trace.php | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 tests/_root/shared/views/errors/500.php create mode 100644 tests/_root/shared/views/errors/trace.php diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index 4b2bd1328..991b5966a 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -19,25 +19,16 @@ class ErrorHandlerTest extends AppTestCase { private ErrorHandler $errorHandler; - private string $traceViewPath; - private string $error500ViewPath; public function setUp(): void { parent::setUp(); $this->errorHandler = new ErrorHandler(); - $this->traceViewPath = PROJECT_ROOT . DS . 'shared' . DS . 'views' . DS . 'errors' . DS . 'trace.php'; - $this->error500ViewPath = PROJECT_ROOT . DS . 'shared' . DS . 'views' . DS . 'errors' . DS . '500.php'; - - $this->createFile($this->traceViewPath, '
      TRACE VIEW :: ::
      '); - $this->createFile($this->error500ViewPath, '
      ERROR 500 VIEW
      '); } public function tearDown(): void { - $this->removeFile($this->traceViewPath); - $this->removeFile($this->error500ViewPath); restore_error_handler(); restore_exception_handler(); parent::tearDown(); 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

      +

      ::

      +
      +
      + + + From 5aed0157c4695b3e1f84e034a45030c7d7002490 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:27:05 +0400 Subject: [PATCH 3/5] [#483] Ensure tracer temp test files are always cleaned up --- tests/Unit/Tracer/ErrorHandlerTest.php | 70 ++++++++++++++------------ 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index 991b5966a..0f8698d99 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -173,33 +173,35 @@ public function testComposeStackTraceSkipsHandlerClassEntriesAndBuildsCodeSnippe $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->removeFile($sourceFile); - - $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']); + try { + $throwable = new Exception('trace'); + $throwableProperty = new \ReflectionProperty(Exception::class, 'file'); + $throwableProperty->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 @@ -278,13 +280,15 @@ public function testGetSourceCodeBuildsOrderedListForLocalAdapter(): void $file = PROJECT_ROOT . DS . 'shared' . DS . 'source-code.php'; $this->createFile($file, "invokePrivateMethod('getSourceCode', [$file, 5, 'error-line']); - - $this->removeFile($file); + try { + $code = $this->invokePrivateMethod('getSourceCode', [$file, 5, 'error-line']); - $this->assertStringContainsString('
            ', $code); - $this->assertStringContainsString('class="error-line"', $code); - $this->assertStringContainsString('
            C
            ', $code); + $this->assertStringContainsString('
              ', $code); + $this->assertStringContainsString('class="error-line"', $code); + $this->assertStringContainsString('
              C
              ', $code); + } finally { + $this->removeFile($file); + } } public function testFormatLineItemEscapesHtmlAndAppliesHighlightClass(): void From 004e2938d59669bc7cb50786a8b3aa181591ed2b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:33:17 +0400 Subject: [PATCH 4/5] [#483] Make tracer CLI exception output testable --- src/Tracer/ErrorHandler.php | 12 +++++++++--- tests/Unit/Tracer/ErrorHandlerTest.php | 7 ++++++- 2 files changed, 15 insertions(+), 4 deletions(-) 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 0f8698d99..8b9161bb5 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -9,6 +9,7 @@ use Quantum\Tracer\ErrorHandler; use Quantum\Di\Di; use Quantum\Logger\Logger; +use Symfony\Component\Console\Output\BufferedOutput; use ReflectionException; use ReflectionMethod; use ErrorException; @@ -103,8 +104,12 @@ public function testErrorTypesConstant(): void public function testHandleExceptionCliWritesExceptionMessage(): void { - $this->expectNotToPerformAssertions(); + $output = new BufferedOutput(); + $this->errorHandler->setCliOutput($output); + $this->errorHandler->handleException(new Exception('CLI failure')); + + $this->assertStringContainsString('CLI failure', $output->fetch()); } public function testHandleWebExceptionInDebugModeRendersTraceView(): void From e20aad44dfe68b42e4010d6c692541affdf48b3c Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:35:53 +0400 Subject: [PATCH 5/5] [#483] Restore filesystem factory state in tracer test --- tests/Unit/Tracer/ErrorHandlerTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index 8b9161bb5..bd03fe966 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -273,11 +273,17 @@ public function listDirectory(string $dirname) $factory = Di::get(FileSystemFactory::class); $instancesProperty = new \ReflectionProperty(FileSystemFactory::class, 'instances'); $instancesProperty->setAccessible(true); - $instancesProperty->setValue($factory, ['local' => new FileSystem($adapter)]); + $originalInstances = $instancesProperty->getValue($factory); - $code = $this->invokePrivateMethod('getSourceCode', [__FILE__, 10, 'error-line']); + try { + $instancesProperty->setValue($factory, ['local' => new FileSystem($adapter)]); + + $code = $this->invokePrivateMethod('getSourceCode', [__FILE__, 10, 'error-line']); - $this->assertSame('', $code); + $this->assertSame('', $code); + } finally { + $instancesProperty->setValue($factory, $originalInstances); + } } public function testGetSourceCodeBuildsOrderedListForLocalAdapter(): void