diff --git a/composer.json b/composer.json index 263117fc8ddc4..073a9391e2352 100644 --- a/composer.json +++ b/composer.json @@ -157,6 +157,7 @@ "symfony/phpunit-bridge": "^6.4|^7.0", "symfony/runtime": "self.version", "symfony/security-acl": "~2.8|~3.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0", "twig/cssinliner-extra": "^2.12|^3", "twig/inky-extra": "^2.12|^3", "twig/markdown-extra": "^2.12|^3", diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 7168caa4d05cd..7ef10bb522af0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -44,6 +44,7 @@ use Symfony\Component\Console\EventListener\ErrorListener; use Symfony\Component\Console\Messenger\RunCommandMessageHandler; use Symfony\Component\Dotenv\Command\DebugCommand as DotenvDebugCommand; +use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand; use Symfony\Component\Messenger\Command\ConsumeMessagesCommand; use Symfony\Component\Messenger\Command\DebugCommand as MessengerDebugCommand; use Symfony\Component\Messenger\Command\FailedMessagesRemoveCommand; @@ -59,6 +60,7 @@ use Symfony\Component\Translation\Command\TranslationPushCommand; use Symfony\Component\Translation\Command\XliffLintCommand; use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand; +use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; return static function (ContainerConfigurator $container) { $container->services() @@ -385,6 +387,14 @@ ]) ->tag('console.command') + ->set('console.command.error_dumper', ErrorDumpCommand::class) + ->args([ + service('filesystem'), + service('error_renderer.html'), + service(EntrypointLookupInterface::class)->nullOnInvalid(), + ]) + ->tag('console.command') + ->set('console.messenger.application', Application::class) ->share(false) ->call('setAutoExit', [false]) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 03707eea39b5f..ec79772917f6f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -23,7 +23,7 @@ "symfony/config": "^7.3", "symfony/dependency-injection": "^7.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", + "symfony/error-handler": "^7.3", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^7.2", diff --git a/src/Symfony/Component/ErrorHandler/CHANGELOG.md b/src/Symfony/Component/ErrorHandler/CHANGELOG.md index c3037ad86deba..cd8d07db6df36 100644 --- a/src/Symfony/Component/ErrorHandler/CHANGELOG.md +++ b/src/Symfony/Component/ErrorHandler/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add `error:dump` command + 7.1 --- diff --git a/src/Symfony/Component/ErrorHandler/Command/ErrorDumpCommand.php b/src/Symfony/Component/ErrorHandler/Command/ErrorDumpCommand.php new file mode 100644 index 0000000000000..95b6f448e43e0 --- /dev/null +++ b/src/Symfony/Component/ErrorHandler/Command/ErrorDumpCommand.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ErrorHandler\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; + +/** + * Dump error pages to plain HTML files that can be directly served by a web server. + * + * @author Loïck Piera + */ +#[AsCommand( + name: 'error:dump', + description: 'Dump error pages to plain HTML files that can be directly served by a web server', +)] +final class ErrorDumpCommand extends Command +{ + public function __construct( + private readonly Filesystem $filesystem, + private readonly ErrorRendererInterface $errorRenderer, + private readonly ?EntrypointLookupInterface $entrypointLookup = null, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('path', InputArgument::REQUIRED, 'Path where to dump the error pages in') + ->addArgument('status-codes', InputArgument::IS_ARRAY, 'Status codes to dump error pages for, all of them by default') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force directory removal before dumping new error pages') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $path = $input->getArgument('path'); + + $io = new SymfonyStyle($input, $output); + $io->title('Dumping error pages'); + + $this->dump($io, $path, $input->getArgument('status-codes'), (bool) $input->getOption('force')); + $io->success(\sprintf('Error pages have been dumped in "%s".', $path)); + + return Command::SUCCESS; + } + + private function dump(SymfonyStyle $io, string $path, array $statusCodes, bool $force = false): void + { + if (!$statusCodes) { + $statusCodes = array_filter(array_keys(Response::$statusTexts), fn ($statusCode) => $statusCode >= 400); + } + + if ($force || ($this->filesystem->exists($path) && $io->confirm(\sprintf('The "%s" directory already exists. Do you want to remove it before dumping the error pages?', $path), false))) { + $this->filesystem->remove($path); + } + + foreach ($statusCodes as $statusCode) { + // Avoid assets to be included only on the first dumped page + $this->entrypointLookup?->reset(); + + $this->filesystem->dumpFile($path.\DIRECTORY_SEPARATOR.$statusCode.'.html', $this->errorRenderer->render(new HttpException((int) $statusCode))->getAsString()); + } + } +} diff --git a/src/Symfony/Component/ErrorHandler/Tests/Command/ErrorDumpCommandTest.php b/src/Symfony/Component/ErrorHandler/Tests/Command/ErrorDumpCommandTest.php new file mode 100644 index 0000000000000..83b2bed754e3a --- /dev/null +++ b/src/Symfony/Component/ErrorHandler/Tests/Command/ErrorDumpCommandTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ErrorHandler\Tests\Command; + +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\TwigBundle\Tests\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand; +use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; + +class ErrorDumpCommandTest extends TestCase +{ + private string $tmpDir = ''; + + protected function setUp(): void + { + $this->tmpDir = sys_get_temp_dir().'/error_pages'; + + $fs = new Filesystem(); + $fs->remove($this->tmpDir); + } + + public function testDumpPages() + { + $tester = $this->getCommandTester($this->getKernel(), []); + $tester->execute([ + 'path' => $this->tmpDir, + ]); + + $this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html'); + $this->assertStringContainsString('Error 404', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html')); + } + + public function testDumpPagesOnlyForGivenStatusCodes() + { + $fs = new Filesystem(); + $fs->mkdir($this->tmpDir); + $fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html'); + + $tester = $this->getCommandTester($this->getKernel()); + $tester->execute([ + 'path' => $this->tmpDir, + 'status-codes' => ['400', '500'], + ]); + + $this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html'); + $this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html'); + + $this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html'); + $this->assertStringContainsString('Error 400', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html')); + } + + public function testForceRemovalPages() + { + $fs = new Filesystem(); + $fs->mkdir($this->tmpDir); + $fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html'); + + $tester = $this->getCommandTester($this->getKernel()); + $tester->execute([ + 'path' => $this->tmpDir, + '--force' => true, + ]); + + $this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html'); + $this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html'); + } + + private function getKernel(): MockObject&KernelInterface + { + return $this->createMock(KernelInterface::class); + } + + private function getCommandTester(KernelInterface $kernel): CommandTester + { + $errorRenderer = $this->createStub(ErrorRendererInterface::class); + $errorRenderer + ->method('render') + ->willReturnCallback(function (HttpException $e) { + $exception = FlattenException::createFromThrowable($e); + $exception->setAsString(\sprintf('Error %s', $e->getStatusCode())); + + return $exception; + }) + ; + + $entrypointLookup = $this->createMock(EntrypointLookupInterface::class); + + $application = new Application($kernel); + $application->add(new ErrorDumpCommand( + new Filesystem(), + $errorRenderer, + $entrypointLookup, + )); + + return new CommandTester($application->find('error:dump')); + } +} diff --git a/src/Symfony/Component/ErrorHandler/composer.json b/src/Symfony/Component/ErrorHandler/composer.json index 987a68f641448..98b94328364e8 100644 --- a/src/Symfony/Component/ErrorHandler/composer.json +++ b/src/Symfony/Component/ErrorHandler/composer.json @@ -21,9 +21,11 @@ "symfony/var-dumper": "^6.4|^7.0" }, "require-dev": { + "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/serializer": "^6.4|^7.0", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5",