diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 777db69..b8f2f7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} + ini-values: zend.exception_ignore_args=Off,zend.exception_string_param_max_len=15 tools: composer - name: Install composer dependencies diff --git a/src/Pages/InternalErrorPage.php b/src/Pages/InternalErrorPage.php new file mode 100644 index 0000000..6a07950 --- /dev/null +++ b/src/Pages/InternalErrorPage.php @@ -0,0 +1,143 @@ +head->append( + FluentHTML::fromTag( 'title' )->addChild( 'Internal Error' ) + ); + $this->error = $error; + $this->setResponseCode( 500 ); + } + + protected function build(): void { + $this->addStyleSheet( 'error-styles.css' ); + $this->head->append( + FluentHTML::make( + 'style', + [], + [ + 'pre { overflow-x: auto; padding-bottom: 10px; }', + '.error-box { width: fit-content; max-width: 100%; }', + ] + ) + ); + $error = $this->error; + $file = self::formatFile( $error->getFile() ); + $this->contentWrapper->append( + FluentHTML::make( + 'div', + [ 'class' => 'error-box' ], + [ + FluentHTML::make( 'h1', [], 'Internal Error' ), + FluentHTML::make( + 'p', + [], + '[' . get_class( $error ) . '] ' . $error->getMessage(), + ), + FluentHTML::make( + 'p', + [], + [ + 'From ', + FluentHTML::make( 'code', [], $file ), + ' line ', + FluentHTML::make( 'code', [], (string)$error->getLine() ), + ] + ), + FluentHTML::make( 'p', [], 'Backtrace:' ), + FluentHTML::make( + 'pre', + [], + self::formatTrace( $error ) + ), + ] + ) + ); + } + + public static function handleException( Throwable $error ) { + try { + $page = new InternalErrorPage( $error ); + $page->getResponse()->applyResponse(); + } catch ( Throwable $error2 ) { + self::handleManually( $error, $error2 ); + } + } + + public static function handleManually( Throwable $error1, Throwable $error2 ) { + http_response_code( 500 ); + echo "\n"; + echo "\n"; + echo "\n"; + echo "Internal Error\n"; + echo "\n"; + echo "\n\n"; + echo "
\n"; + + echo "

Internal Error

\n"; + echo '

[' . get_class( $error1 ) . '] ' . $error1->getMessage() . "

\n"; + $file = self::formatFile( $error1->getFile() ); + echo "

From: $file line " . $error1->getLine() . "

\n"; + echo "

Backtrace:

\n"; + echo "
" . self::formatTrace( $error1 ) . "
\n"; + + echo "

While trying to handle that error, the handler also had an error:

\n"; + echo '

[' . get_class( $error2 ) . '] ' . $error2->getMessage() . "

\n"; + $file = self::formatFile( $error2->getFile() ); + echo "

From: $file line " . $error2->getLine() . "

\n"; + echo "

Backtrace:

\n"; + echo "
" . self::formatTrace( $error2 ) . "
\n"; + + echo "
"; + } + + /** + * Like Exception::getTraceAsString() but + * - the `/var/www/html/` is removed from the start of files + */ + private static function formatTrace( Throwable $error ): string { + // Don't want to try and reimplement the entirety of the exception + // formatting + $trace = $error->getTraceAsString(); + $lines = explode( "\n", $trace ); + $betterLines = array_map( + static fn ( $line ) => preg_replace_callback( + "/^(#\d+) ([^\(]+)(\(\d+\): .*$)/", + static fn ( array $matches ): string => $matches[1] + . ' ' + . self::formatFile( $matches[2] ) + . $matches[3], + $line + ), + $lines + ); + return implode( "\n", $betterLines ); + } + + /** + * Nicely format file names, removing common leading prefixes + */ + private static function formatFile( string $file ): string { + // Within docker container + if ( str_starts_with( $file, '/var/www/html/' ) ) { + return '.../' . substr( $file, strlen( '/var/www/html/' ) ); + } + // Within GitHub actions + $ghPrefix = '/home/runner/work/website-content/website-content/'; + if ( str_starts_with( $file, $ghPrefix ) ) { + return '.../' . substr( $file, strlen( $ghPrefix ) ); + } + return $file; + } + +} diff --git a/src/setup.php b/src/setup.php index 5824f04..ac86fdf 100644 --- a/src/setup.php +++ b/src/setup.php @@ -20,3 +20,5 @@ // Autoloading from composer require_once __DIR__ . '/../vendor/autoload.php'; + +set_exception_handler( [ \DanielWebsite\Pages\InternalErrorPage::class, 'handleException' ] ); diff --git a/tests/StaticOutputTest.php b/tests/StaticOutputTest.php index 9932ec6..596f0ed 100644 --- a/tests/StaticOutputTest.php +++ b/tests/StaticOutputTest.php @@ -13,6 +13,7 @@ use DanielWebsite\Pages\BlogPostPage; use DanielWebsite\Pages\Error404Page; use DanielWebsite\Pages\Error405Page; +use DanielWebsite\Pages\InternalErrorPage; use DanielWebsite\Pages\LandingPage; use DanielWebsite\Pages\OpenSourcePage; use DanielWebsite\Pages\RedirectPage; @@ -20,6 +21,7 @@ use DanielWebsite\Pages\ToolPage; use DanielWebsite\Pages\WorkPage; use DanielWebsite\Router; +use Exception; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -30,6 +32,7 @@ #[CoversClass( BlogPostPage::class )] #[CoversClass( Error404Page::class )] #[CoversClass( Error405Page::class )] +#[CoversClass( InternalErrorPage::class )] #[CoversClass( LandingPage::class )] #[CoversClass( OpenSourcePage::class )] #[CoversClass( RedirectPage::class )] @@ -98,4 +101,53 @@ public function testRedirect() { $this->assertInstanceOf( RedirectPage::class, $page ); } + public function testErrorNice() { + ob_start(); + InternalErrorPage::handleException( new Exception( 'testing' ) ); + $output = ob_get_clean(); + // Different include paths in docker and on GitHub + $output = preg_replace( + "/(\/vendor\/bin\/phpunit\(122\):) include\([^\)]+\)/", + "$1 include({path})", + $output + ); + // Don't bind to exact line numbers + $output = preg_replace( + "/(vendor\/phpunit\/[^(]+)\(\d+\): /", + "$1({line}): ", + $output + ); + $filePath = __DIR__ . '/data/errors-nice.html'; + if ( getenv( 'TESTS_UPDATE_EXPECTED' ) === '1' ) { + file_put_contents( $filePath, $output ); + } + $this->assertStringEqualsFile( $filePath, $output ); + } + + public function testErrorManual() { + ob_start(); + InternalErrorPage::handleManually( + new Exception( 'first' ), + new Exception( 'second' ) + ); + $output = ob_get_clean(); + // Different include paths in docker and on GitHub + $output = preg_replace( + "/(\/vendor\/bin\/phpunit\(122\):) include\([^\)]+\)/", + "$1 include({path})", + $output + ); + // Don't bind to exact line numbers + $output = preg_replace( + "/(vendor\/phpunit\/[^(]+)\(\d+\): /", + "$1({line}): ", + $output + ); + $filePath = __DIR__ . '/data/errors-manual.html'; + if ( getenv( 'TESTS_UPDATE_EXPECTED' ) === '1' ) { + file_put_contents( $filePath, $output ); + } + $this->assertStringEqualsFile( $filePath, $output ); + } + } diff --git a/tests/data/errors-manual.html b/tests/data/errors-manual.html new file mode 100644 index 0000000..22127b3 --- /dev/null +++ b/tests/data/errors-manual.html @@ -0,0 +1,41 @@ + + + +Internal Error + + + +
+

Internal Error

+

[Exception] first

+

From: .../tests/StaticOutputTest.php line 130

+

Backtrace:

+
#0 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): DanielWebsite\Tests\StaticOutputTest->testErrorManual()
+#1 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestCase->runTest()
+#2 .../vendor/phpunit/phpunit/src/Framework/TestRunner/TestRunner.php({line}): PHPUnit\Framework\TestCase->runBare()
+#3 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestRunner->run(Object(DanielWebsite\Tests\StaticOutputTest))
+#4 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestCase->run()
+#5 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#6 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#7 .../vendor/phpunit/phpunit/src/TextUI/TestRunner.php({line}): PHPUnit\Framework\TestSuite->run()
+#8 .../vendor/phpunit/phpunit/src/TextUI/Application.php({line}): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\TextUI\Configuration\Configuration), Object(PHPUnit\Runner\ResultCache\DefaultResultCache), Object(PHPUnit\Framework\TestSuite))
+#9 .../vendor/phpunit/phpunit/phpunit({line}): PHPUnit\TextUI\Application->run(Array)
+#10 .../vendor/bin/phpunit(122): include({path})
+#11 {main}
+

While trying to handle that error, the handler also had an error:

+

[Exception] second

+

From: .../tests/StaticOutputTest.php line 131

+

Backtrace:

+
#0 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): DanielWebsite\Tests\StaticOutputTest->testErrorManual()
+#1 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestCase->runTest()
+#2 .../vendor/phpunit/phpunit/src/Framework/TestRunner/TestRunner.php({line}): PHPUnit\Framework\TestCase->runBare()
+#3 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestRunner->run(Object(DanielWebsite\Tests\StaticOutputTest))
+#4 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestCase->run()
+#5 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#6 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#7 .../vendor/phpunit/phpunit/src/TextUI/TestRunner.php({line}): PHPUnit\Framework\TestSuite->run()
+#8 .../vendor/phpunit/phpunit/src/TextUI/Application.php({line}): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\TextUI\Configuration\Configuration), Object(PHPUnit\Runner\ResultCache\DefaultResultCache), Object(PHPUnit\Framework\TestSuite))
+#9 .../vendor/phpunit/phpunit/phpunit({line}): PHPUnit\TextUI\Application->run(Array)
+#10 .../vendor/bin/phpunit(122): include({path})
+#11 {main}
+
\ No newline at end of file diff --git a/tests/data/errors-nice.html b/tests/data/errors-nice.html new file mode 100644 index 0000000..432a67b --- /dev/null +++ b/tests/data/errors-nice.html @@ -0,0 +1,13 @@ + +Internal Error
HomeRésuméOpen SourceWorkBlog

Internal Error

[Exception] testing

From .../tests/StaticOutputTest.php line 106

Backtrace:

#0 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): DanielWebsite\Tests\StaticOutputTest->testErrorNice()
+#1 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestCase->runTest()
+#2 .../vendor/phpunit/phpunit/src/Framework/TestRunner/TestRunner.php({line}): PHPUnit\Framework\TestCase->runBare()
+#3 .../vendor/phpunit/phpunit/src/Framework/TestCase.php({line}): PHPUnit\Framework\TestRunner->run(Object(DanielWebsite\Tests\StaticOutputTest))
+#4 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestCase->run()
+#5 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#6 .../vendor/phpunit/phpunit/src/Framework/TestSuite.php({line}): PHPUnit\Framework\TestSuite->run()
+#7 .../vendor/phpunit/phpunit/src/TextUI/TestRunner.php({line}): PHPUnit\Framework\TestSuite->run()
+#8 .../vendor/phpunit/phpunit/src/TextUI/Application.php({line}): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\TextUI\Configuration\Configuration), Object(PHPUnit\Runner\ResultCache\DefaultResultCache), Object(PHPUnit\Framework\TestSuite))
+#9 .../vendor/phpunit/phpunit/phpunit({line}): PHPUnit\TextUI\Application->run(Array)
+#10 .../vendor/bin/phpunit(122): include({path})
+#11 {main}
\ No newline at end of file