From af056ae6839d4bac670df1d45fc26eb3fc22dbf5 Mon Sep 17 00:00:00 2001 From: omkar podey <58183-omkar.podey@users.noreply.drupalcode.org> Date: Mon, 17 Apr 2023 19:48:10 +0000 Subject: [PATCH] Issue #3338666 by omkar.podey, phenaproxima, tedbow, Wim Leers: Add functional test that proves there is reasonable UX whenever a stage event subscriber has an exception --- src/BatchProcessor.php | 89 ++++++------- src/Form/UpdaterForm.php | 8 +- tests/src/Functional/UpdateErrorTest.php | 160 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 47 deletions(-) diff --git a/src/BatchProcessor.php b/src/BatchProcessor.php index c4656c126f..ce81085c4a 100644 --- a/src/BatchProcessor.php +++ b/src/BatchProcessor.php @@ -32,6 +32,13 @@ final class BatchProcessor { */ public const MAINTENANCE_MODE_SESSION_KEY = '_automatic_updates_maintenance_mode'; + /** + * The session key which stores error messages that occur in processing. + * + * @var string + */ + private const ERROR_MESSAGES_SESSION_KEY = '_automatic_updates_errors'; + /** * Gets the update stage service. * @@ -47,15 +54,24 @@ final class BatchProcessor { * * @param \Throwable $error * The caught exception. - * @param array $context - * The current context of the batch job. * * @throws \Throwable * The caught exception, which will always be re-thrown once its messages * have been recorded. */ - protected static function handleException(\Throwable $error, array &$context): void { - $context['results']['errors'][] = $error->getMessage(); + protected static function handleException(\Throwable $error): never { + // TRICKY: We need to store error messages in the session because the batch + // context becomes a dangling reference when static variables are globally + // reset by drupal_flush_all_caches(), which is called during the post-apply + // phase of the update. Which means that, when ::postApply() is called, any + // data added to the batch context in the current request is lost. On the + // other hand, data stored in the session is not affected. + /** @var \Symfony\Component\HttpFoundation\Session\SessionInterface $session */ + $session = \Drupal::service('session'); + $errors = $session->get(self::ERROR_MESSAGES_SESSION_KEY, []); + $errors[] = $error->getMessage(); + $session->set(self::ERROR_MESSAGES_SESSION_KEY, $errors); + throw $error; } @@ -64,37 +80,32 @@ final class BatchProcessor { * * @param string[] $project_versions * The project versions to be staged in the update, keyed by package name. - * @param array $context - * The current context of the batch job. * * @see \Drupal\automatic_updates\UpdateStage::begin() */ - public static function begin(array $project_versions, array &$context): void { + public static function begin(array $project_versions): void { try { $stage_id = static::getStage()->begin($project_versions); \Drupal::service('session')->set(static::STAGE_ID_SESSION_KEY, $stage_id); } catch (\Throwable $e) { - static::handleException($e, $context); + static::handleException($e); } } /** * Calls the update stage's stage() method. * - * @param array $context - * The current context of the batch job. - * * @see \Drupal\automatic_updates\UpdateStage::stage() */ - public static function stage(array &$context): void { + public static function stage(): void { $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); try { static::getStage()->claim($stage_id)->stage(); } catch (\Throwable $e) { - static::clean($stage_id, $context); - static::handleException($e, $context); + static::clean($stage_id); + static::handleException($e); } } @@ -103,12 +114,10 @@ final class BatchProcessor { * * @param string $stage_id * The stage ID. - * @param array $context - * The current context of the batch job. * * @see \Drupal\automatic_updates\UpdateStage::apply() */ - public static function commit(string $stage_id, array &$context): void { + public static function commit(string $stage_id): void { try { static::getStage()->claim($stage_id)->apply(); // The batch system does not allow any single request to run for longer @@ -120,7 +129,7 @@ final class BatchProcessor { sleep(1); } catch (\Throwable $e) { - static::handleException($e, $context); + static::handleException($e); } } @@ -129,17 +138,15 @@ final class BatchProcessor { * * @param string $stage_id * The stage ID. - * @param array $context - * The current context of the batch job. * * @see \Drupal\automatic_updates\UpdateStage::postApply() */ - public static function postApply(string $stage_id, array &$context): void { + public static function postApply(string $stage_id): void { try { static::getStage()->claim($stage_id)->postApply(); } catch (\Throwable $e) { - static::handleException($e, $context); + static::handleException($e); } } @@ -148,17 +155,15 @@ final class BatchProcessor { * * @param string $stage_id * The stage ID. - * @param array $context - * The current context of the batch job. * * @see \Drupal\automatic_updates\UpdateStage::destroy() */ - public static function clean(string $stage_id, array &$context): void { + public static function clean(string $stage_id): void { try { static::getStage()->claim($stage_id)->destroy(); } catch (\Throwable $e) { - static::handleException($e, $context); + static::handleException($e); } } @@ -167,12 +172,8 @@ final class BatchProcessor { * * @param bool $success * Indicate that the batch API tasks were all completed successfully. - * @param array $results - * An array of all the results. - * @param array $operations - * A list of the operations that had not been completed by the batch API. */ - public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse { + public static function finishStage(bool $success): ?RedirectResponse { if ($success) { $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); $url = Url::fromRoute('automatic_updates.confirmation_page', [ @@ -180,7 +181,7 @@ final class BatchProcessor { ]); return new RedirectResponse($url->setAbsolute()->toString()); } - static::handleBatchError($results); + static::handleBatchError(); return NULL; } @@ -189,12 +190,8 @@ final class BatchProcessor { * * @param bool $success * Indicate that the batch API tasks were all completed successfully. - * @param array $results - * An array of all the results. - * @param array $operations - * A list of the operations that had not been completed by the batch API. */ - public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse { + public static function finishCommit(bool $success): ?RedirectResponse { \Drupal::service('session')->remove(static::STAGE_ID_SESSION_KEY); if ($success) { @@ -203,21 +200,21 @@ final class BatchProcessor { ->toString(); return new RedirectResponse($url); } - static::handleBatchError($results); + static::handleBatchError(); return NULL; } /** * Handles a batch job that finished with errors. - * - * @param array $results - * The batch results. */ - protected static function handleBatchError(array $results): void { - if (isset($results['errors'])) { - foreach ($results['errors'] as $error) { - \Drupal::messenger()->addError($error); - } + protected static function handleBatchError(): void { + /** @var \Symfony\Component\HttpFoundation\Session\SessionInterface $session */ + $session = \Drupal::service('session'); + $errors = $session->get(self::ERROR_MESSAGES_SESSION_KEY); + $session->remove(self::ERROR_MESSAGES_SESSION_KEY); + + if (is_array($errors)) { + array_walk($errors, \Drupal::messenger()->addError(...)); } else { \Drupal::messenger()->addError("Update error"); diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php index ee7768166c..79c1f558c9 100644 --- a/src/Form/UpdaterForm.php +++ b/src/Form/UpdaterForm.php @@ -152,7 +152,13 @@ final class UpdaterForm extends UpdateFormBase { $results = []; } else { - $results = $this->runStatusCheck($this->stage, $this->eventDispatcher); + try { + $results = $this->runStatusCheck($this->stage, $this->eventDispatcher); + } + catch (\Throwable $e) { + $this->messenger()->addError($e->getMessage()); + return $form; + } } $this->displayResults($results, $this->renderer); $project = $project_info->getProjectInfo(); diff --git a/tests/src/Functional/UpdateErrorTest.php b/tests/src/Functional/UpdateErrorTest.php index b5666fb012..52cfd10f2d 100644 --- a/tests/src/Functional/UpdateErrorTest.php +++ b/tests/src/Functional/UpdateErrorTest.php @@ -4,8 +4,14 @@ declare(strict_types = 1); namespace Drupal\Tests\automatic_updates\Functional; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostDestroyEvent; use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreDestroyEvent; +use Drupal\package_manager\Event\PreRequireEvent; use Drupal\package_manager\Event\StatusCheckEvent; use Drupal\package_manager\ValidationResult; use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1; @@ -16,9 +22,23 @@ use Drupal\system\SystemManager; * @covers \Drupal\automatic_updates\Form\UpdaterForm * @group automatic_updates * @internal + * + * @todo Consolidate and remove duplicate test coverage in + * https://drupal.org/i/3354325. */ class UpdateErrorTest extends UpdaterFormTestBase { + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->config('system.logging') + ->set('error_level', ERROR_REPORTING_DISPLAY_VERBOSE) + ->save(); + } + /** * Tests that the update stage is destroyed if an error occurs during require. */ @@ -164,4 +184,144 @@ class UpdateErrorTest extends UpdaterFormTestBase { $assert_session->pageTextNotContains($cached_message); } + /** + * Tests handling of exceptions thrown by event subscribers. + * + * @param string $event + * The event that should throw an exception. + * + * @dataProvider providerExceptionFromEventSubscriber + */ + public function testExceptionFromEventSubscriber(string $event): void { + $exception = new \Exception('Bad news bears!'); + TestSubscriber::setException($exception, $event); + + // Only simulate a staged update if we're going to get far enough that the + // stage directory will be created. + if ($event !== StatusCheckEvent::class && $event !== PreCreateEvent::class) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + } + + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + + // StatusCheckEvent runs very early, before we can even start the update. + // If it raises the error we're expecting, we're done. + if ($event === StatusCheckEvent::class) { + $assert_session->statusMessageContains($exception->getMessage(), 'error'); + // We shouldn't be able to start the update. + $assert_session->buttonNotExists('Update to 9.8.1'); + return; + } + + // Start the update. + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + // If the batch job fails, proceed to the error page. If it failed because + // of the exception we set up, we're done. + if ($page->hasLink('the error page')) { + // We should see the exception's backtrace. + $assert_session->responseContains('<pre class="backtrace">'); + $page->clickLink('the error page'); + $assert_session->statusMessageContains($exception->getMessage(), 'error'); + // We should be on the start page. + $assert_session->addressEquals('/admin/modules/update'); + + // If we failed during post-create, the stage is not destroyed, so we + // should not be able to start the update anew without destroying the + // stage first. In all other cases, the stage should have been destroyed + // and we should be able to try again. + // @todo Delete the existing update on behalf of the user in + // https://drupal.org/i/3346644. + if ($event === PostCreateEvent::class) { + $assert_session->buttonNotExists('Update to 9.8.1'); + $assert_session->buttonExists('Delete existing update'); + } + else { + $assert_session->buttonExists('Update to 9.8.1'); + $assert_session->buttonNotExists('Delete existing update'); + } + return; + } + + // We should now be ready to finish the update... + $this->assertStringContainsString('/admin/automatic-update-ready/', $session->getCurrentUrl()); + // ...but if we set it up to fail on PostRequireEvent, and we see the error + // message from that, we're done. + // @todo In https://drupal.org/i/3346644, ensure that PostRequireEvent + // behaves the same way as PreCreateEvent, PostCreateEvent, and + // PreRequireEvent. + if ($event === PostRequireEvent::class) { + $assert_session->statusMessageContains($exception->getMessage(), 'error'); + $assert_session->buttonNotExists('Continue'); + return; + } + + // Ensure that we are expecting a failure from an event that is dispatched + // during the second phase (apply and destroy) of the update. + $this->assertContains($event, [ + PreApplyEvent::class, + PostApplyEvent::class, + PreDestroyEvent::class, + PostDestroyEvent::class, + ]); + // Try to finish the update. + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + // As we did before, proceed to the error page if the batch job fails. If it + // failed because of the exception we set up, we're done here. + if ($page->hasLink('the error page')) { + // We should see the exception's backtrace. + $assert_session->responseContains('<pre class="backtrace">'); + $page->clickLink('the error page'); + $assert_session->statusMessageContains($exception->getMessage(), 'error'); + // We should be back on the "ready to update" page. + $this->assertStringContainsString('/admin/automatic-update-ready/', $session->getCurrentUrl()); + return; + } + $this->fail('Expected to encounter an error message during the update process.'); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Ensure that any pre- or post-destroy exception we set up during testing + // does not interfere with the parent class' ability to destroy the stage. + TestSubscriber::setException(NULL, PreDestroyEvent::class); + TestSubscriber::setException(NULL, PostDestroyEvent::class); + + parent::tearDown(); + } + + /** + * Data provider for ::testExceptionFromEventSubscriber(). + * + * @return array[] + * The test cases. + */ + public function providerExceptionFromEventSubscriber(): array { + $events = [ + StatusCheckEvent::class, + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + PreDestroyEvent::class, + // @todo PostDestroyEvent leads to an exception with "This operation was + // already canceled". This is because the batch processor redirects to + // the UpdateReady form, which tries to claim the stage...which has been + // destroyed. Fix this in https://drupal.org/i/3354003. + // PostDestroyEvent::class, + ]; + return array_combine($events, array_map(fn ($event) => [$event], $events)); + } + } -- GitLab