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