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
+ = $severity ?> :: = $errorMessage ?>
+ = count($stackTrace) ?>
+
+
+
+