Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/Tracer/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,6 +63,7 @@ class ErrorHandler
];

private ?Logger $logger = null;
private ?OutputInterface $cliOutput = null;

private function __clone()
{
Expand All @@ -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
*/
Expand All @@ -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
{
Expand All @@ -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('<error>' . $throwable->getMessage() . '</error>');
}

Expand Down
255 changes: 255 additions & 0 deletions tests/Unit/Tracer/ErrorHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Comment thread
armanist marked this conversation as resolved.

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, "<?php\nline1\nline2\nline3\nline4\nline5\n");

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('<ol start="1">', $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);
}
}
Comment thread
armanist marked this conversation as resolved.

public function testGetSourceCodeBuildsOrderedListForLocalAdapter(): void
{
$file = PROJECT_ROOT . DS . 'shared' . DS . 'source-code.php';
$this->createFile($file, "<?php\nA\nB\nC\nD\nE\nF\nG\n");

try {
$code = $this->invokePrivateMethod('getSourceCode', [$file, 5, 'error-line']);

$this->assertStringContainsString('<ol start="1">', $code);
$this->assertStringContainsString('class="error-line"', $code);
$this->assertStringContainsString('<pre>C</pre>', $code);
} finally {
$this->removeFile($file);
}
}

public function testFormatLineItemEscapesHtmlAndAppliesHighlightClass(): void
{
$html = $this->invokePrivateMethod('formatLineItem', [5, '<div class="x">A&B</div>', 5, 'switch-line']);
$plain = $this->invokePrivateMethod('formatLineItem', [4, 'plain', 5, 'switch-line']);

$this->assertStringContainsString('class="switch-line"', $html);
$this->assertStringContainsString('&lt;div class=&quot;x&quot;&gt;A&amp;B&lt;/div&gt;', $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<string, array{0: \Throwable, 1: string}>
*/
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<int, mixed> $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);
}
}
16 changes: 16 additions & 0 deletions tests/_root/shared/views/errors/500.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Error Page 500</title>
</head>

<body>

<main>
<h1>500</h1>
<h3>ERROR 500 VIEW</h3>
</main>

</body>
</html>
17 changes: 17 additions & 0 deletions tests/_root/shared/views/errors/trace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Error Trace</title>
</head>

<body>

<main>
<h1>TRACE VIEW</h1>
<h3><?= $severity ?> :: <?= $errorMessage ?></h3>
<div><?= count($stackTrace) ?></div>
</main>

</body>
</html>
Loading