Issue #3294914 by Spokje, quietone, bbrala, longwave, Gábor Hojtsy, benjifisher, dww, xjm, rkoller: Create dedicated error section for missing removed core modules/themes on update

merge-requests/982/head
catch 2022-12-02 10:53:59 +00:00
parent 7643161492
commit 22a56817e0
2 changed files with 382 additions and 87 deletions

View File

@ -28,6 +28,30 @@ use Drupal\Core\Url;
use GuzzleHttp\Exception\TransferException;
use Symfony\Component\HttpFoundation\Request;
// cspell:ignore quickedit
/**
* An array of machine names of modules that were removed from Drupal core.
*/
const DRUPAL_CORE_REMOVED_MODULE_LIST = [
'aggregator' => 'Aggregator',
'ckeditor' => 'CKEditor',
'color' => 'Color',
'hal' => 'HAL',
'quickedit' => 'Quick Edit',
'rdf' => 'RDF',
];
/**
* An array of machine names of themes that were removed from Drupal core.
*/
const DRUPAL_CORE_REMOVED_THEME_LIST = [
'bartik' => 'Bartik',
'classy' => 'Classy',
'seven' => 'Seven',
'stable' => 'Stable',
];
/**
* Implements hook_requirements().
*/
@ -989,13 +1013,16 @@ function system_requirements($phase) {
// Display an error if a newly introduced dependency in a module is not resolved.
if ($phase === 'update' || $phase === 'runtime') {
$create_extension_incompatibility_list = function ($extension_names, $description, $title) {
$create_extension_incompatibility_list = function (array $extension_names, PluralTranslatableMarkup $description, PluralTranslatableMarkup $title, TranslatableMarkup|string $message = '', TranslatableMarkup|string $additional_description = '') {
if ($message === '') {
$message = new TranslatableMarkup('Review the <a href=":url"> suggestions for resolving this incompatibility</a> to repair your installation, and then re-run update.php.', [':url' => 'https://www.drupal.org/docs/updating-drupal/troubleshooting-database-updates']);
}
// Use an inline twig template to:
// - Concatenate two MarkupInterface objects and preserve safeness.
// - Concatenate MarkupInterface objects and preserve safeness.
// - Use the item_list theme for the extension list.
$template = [
'#type' => 'inline_template',
'#template' => '{{ description }}{{ extensions }}',
'#template' => '{{ description }}{{ extensions }}{{ additional_description }}<br>',
'#context' => [
'extensions' => [
'#theme' => 'item_list',
@ -1004,15 +1031,13 @@ function system_requirements($phase) {
];
$template['#context']['extensions']['#items'] = $extension_names;
$template['#context']['description'] = $description;
$template['#context']['additional_description'] = $additional_description;
return [
'title' => $title,
'value' => [
'list' => $template,
'handbook_link' => [
'#markup' => t(
'Review the <a href=":url"> suggestions for resolving this incompatibility</a> to repair your installation, and then re-run update.php.',
[':url' => 'https://www.drupal.org/docs/8/update/troubleshooting-database-updates']
),
'#markup' => $message,
],
],
'severity' => REQUIREMENT_ERROR,
@ -1134,13 +1159,89 @@ function system_requirements($phase) {
);
}
// Look for invalid modules.
$extension_config = \Drupal::configFactory()->get('core.extension');
$is_missing_extension = function ($extension_name) use (&$module_extension_list) {
return !$module_extension_list->exists($extension_name);
};
$invalid_modules = array_filter(array_keys($extension_config->get('module')), $is_missing_extension);
// Look for removed core modules.
$is_removed_module = function ($extension_name) use ($module_extension_list) {
return !$module_extension_list->exists($extension_name)
&& array_key_exists($extension_name, DRUPAL_CORE_REMOVED_MODULE_LIST);
};
$removed_modules = array_filter(array_keys($extension_config->get('module')), $is_removed_module);
if (!empty($removed_modules)) {
$list = [];
foreach ($removed_modules as $removed_module) {
$list[] = t('<a href=":url">@module</a>', [
':url' => "https://www.drupal.org/project/$removed_module",
'@module' => DRUPAL_CORE_REMOVED_MODULE_LIST[$removed_module],
]);
}
$requirements['removed_module'] = $create_extension_incompatibility_list(
$list,
new PluralTranslatableMarkup(
count($removed_modules),
'You must add the following contributed module and reload this page.',
'You must add the following contributed modules and reload this page.'
),
new PluralTranslatableMarkup(
count($removed_modules),
'Removed core module',
'Removed core modules'
),
new TranslatableMarkup(
'For more information read the <a href=":url">documentation on deprecated modules.</a>',
[':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-modules']
),
new PluralTranslatableMarkup(
count($removed_modules),
'This module is installed on your site but is no longer provided by Core.',
'These modules are installed on your site but are no longer provided by Core.'
),
);
}
// Look for removed core themes.
$is_removed_theme = function ($extension_name) use ($theme_extension_list) {
return !$theme_extension_list->exists($extension_name)
&& array_key_exists($extension_name, DRUPAL_CORE_REMOVED_THEME_LIST);
};
$removed_themes = array_filter(array_keys($extension_config->get('theme')), $is_removed_theme);
if (!empty($removed_themes)) {
$list = [];
foreach ($removed_themes as $removed_theme) {
$list[] = t('<a href=":url">@theme</a>', [
':url' => "https://www.drupal.org/project/$removed_theme",
'@theme' => DRUPAL_CORE_REMOVED_THEME_LIST[$removed_theme],
]);
}
$requirements['removed_theme'] = $create_extension_incompatibility_list(
$list,
new PluralTranslatableMarkup(
count($removed_themes),
'You must add the following contributed theme and reload this page.',
'You must add the following contributed themes and reload this page.'
),
new PluralTranslatableMarkup(
count($removed_themes),
'Removed core theme',
'Removed core themes'
),
new TranslatableMarkup(
'For more information read the <a href=":url">documentation on deprecated themes.</a>',
[':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-themes']
),
new PluralTranslatableMarkup(
count($removed_themes),
'This theme is installed on your site but is no longer provided by Core.',
'These themes are installed on your site but are no longer provided by Core.'
),
);
}
// Look for missing modules.
$is_missing_module = function ($extension_name) use ($module_extension_list) {
return !$module_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_MODULE_LIST), TRUE);
};
$invalid_modules = array_filter(array_keys($extension_config->get('module')), $is_missing_module);
if (!empty($invalid_modules)) {
$requirements['invalid_module'] = $create_extension_incompatibility_list(
@ -1160,7 +1261,7 @@ function system_requirements($phase) {
// Look for invalid themes.
$is_missing_theme = function ($extension_name) use (&$theme_extension_list) {
return !$theme_extension_list->exists($extension_name);
return !$theme_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_THEME_LIST), TRUE);
};
$invalid_themes = array_filter(array_keys($extension_config->get('theme')), $is_missing_theme);
if (!empty($invalid_themes)) {

View File

@ -224,7 +224,7 @@ class UpdateScriptTest extends BrowserTestBase {
*
* @dataProvider providerExtensionCompatibilityChange
*/
public function testExtensionCompatibilityChange(array $correct_info, array $breaking_info, $expected_error) {
public function testExtensionCompatibilityChange(array $correct_info, array $breaking_info, string $expected_error): void {
$extension_type = $correct_info['type'];
$this->drupalLogin(
$this->drupalCreateUser(
@ -236,8 +236,9 @@ class UpdateScriptTest extends BrowserTestBase {
)
);
$extension_machine_name = "changing_extension";
$extension_name = "$extension_machine_name name";
$extension_machine_names = ['changing_extension'];
$extension_name = "$extension_machine_names[0] name";
$test_error_urls = ['https://www.drupal.org/docs/updating-drupal/troubleshooting-database-updates'];
$test_error_text = "Incompatible $extension_type "
. $expected_error
@ -247,25 +248,28 @@ class UpdateScriptTest extends BrowserTestBase {
if ($extension_type === 'theme') {
$base_info['base theme'] = FALSE;
}
$folder_path = \Drupal::getContainer()->getParameter('site.path') . "/{$extension_type}s/$extension_machine_name";
$file_path = "$folder_path/$extension_machine_name.info.yml";
$folder_path = \Drupal::getContainer()->getParameter('site.path') . "/{$extension_type}s/$extension_machine_names[0]";
$file_path = "$folder_path/$extension_machine_names[0].info.yml";
mkdir($folder_path, 0777, TRUE);
file_put_contents($file_path, Yaml::encode($base_info + $correct_info));
$this->enableExtension($extension_type, $extension_machine_name, $extension_name);
$this->assertInstalledExtensionConfig($extension_type, $extension_machine_name);
$this->enableExtensions($extension_type, $extension_machine_names, [$extension_name]);
$this->assertInstalledExtensionsConfig($extension_type, $extension_machine_names);
// If there are no requirements warnings or errors, we expect to be able to
// go through the update process uninterrupted.
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
$this->drupalGet($this->statusReportUrl);
$this->assertUpdateWithNoErrors([$test_error_text], $extension_type, $extension_machine_names);
// Change the values in the info.yml and confirm updating is not possible.
file_put_contents($file_path, Yaml::encode($base_info + $breaking_info));
$this->assertErrorOnUpdate($test_error_text, $extension_type, $extension_machine_name);
$this->drupalGet($this->statusReportUrl);
$this->assertErrorOnUpdates([$test_error_text], $extension_type, $extension_machine_names, $test_error_urls);
// Fix the values in the info.yml file and confirm updating is possible
// again.
file_put_contents($file_path, Yaml::encode($base_info + $correct_info));
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
$this->drupalGet($this->statusReportUrl);
$this->assertUpdateWithNoErrors([$test_error_text], $extension_type, $extension_machine_names);
}
/**
@ -329,53 +333,165 @@ class UpdateScriptTest extends BrowserTestBase {
/**
* Tests that a missing extension prevents updates.
*
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param array $core
* An array keyed by 'module' and 'theme' where each sub array contains
* a list of extension machine names.
* @param array $contrib
* An array keyed by 'module' and 'theme' where each sub array contains
* a list of extension machine names.
*
* @dataProvider providerMissingExtension
*/
public function testMissingExtension($extension_type) {
public function testMissingExtension(array $core, array $contrib): void {
$this->drupalLogin(
$this->drupalCreateUser(
[
'administer software updates',
'administer site configuration',
$extension_type === 'module' ? 'administer modules' : 'administer themes',
'administer modules',
'administer themes',
]
)
);
$extension_machine_name = "disappearing_$extension_type";
$extension_name = 'The magically disappearing extension';
$test_error_text = "Missing or invalid $extension_type "
. "The following $extension_type is marked as installed in the core.extension configuration, but it is missing:"
. $extension_machine_name
. static::HANDBOOK_MESSAGE;
$extension_info = [
'name' => $extension_name,
'type' => $extension_type,
$all_extensions_info = [];
$file_paths = [];
$test_error_texts = [];
$test_error_urls = [];
$extension_base_info = [
'version' => 'VERSION',
'core_version_requirement' => '^8 || ^9 || ^10',
];
if ($extension_type === 'theme') {
$extension_info['base theme'] = FALSE;
// For each core extension create and error of info.yml information and
// the expected error message.
foreach ($core as $type => $extensions) {
$removed_list = [];
$error_url = 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-modules';
$extension_base_info += ['package' => 'Core'];
if ($type === 'module') {
$removed_core_list = \DRUPAL_CORE_REMOVED_MODULE_LIST;
}
else {
$removed_core_list = \DRUPAL_CORE_REMOVED_THEME_LIST;
}
foreach ($extensions as $extension) {
$extension_info = $extension_base_info +
[
'name' => "The magically disappearing core $type $extension",
'type' => $type,
];
if ($type === 'theme') {
$extension_info['base theme'] = FALSE;
}
$all_extensions_info[$extension] = $extension_info;
$removed_list[] = $removed_core_list[$extension];
}
// Create the requirements test message.
if (!empty($extensions)) {
$handbook_message = "For more information read the documentation on deprecated {$type}s.";
if (count($removed_list) === 1) {
$test_error_texts[$type][] = "Removed core {$type} "
. "You must add the following contributed $type and reload this page."
. implode($removed_list)
. "This $type is installed on your site but is no longer provided by Core."
. $handbook_message;
}
else {
$test_error_texts[$type][] = "Removed core {$type}s "
. "You must add the following contributed {$type}s and reload this page."
. implode($removed_list)
. "These {$type}s are installed on your site but are no longer provided by Core."
. $handbook_message;
}
$test_error_urls[$type][] = $error_url;
}
}
// For each contrib extension create and error of info.yml information and
// the expected error message.
foreach ($contrib as $type => $extensions) {
unset($extension_base_info['package']);
$handbook_message = 'Review the suggestions for resolving this incompatibility to repair your installation, and then re-run update.php.';
$error_url = 'https://www.drupal.org/docs/updating-drupal/troubleshooting-database-updates';
foreach ($extensions as $extension) {
$extension_info = $extension_base_info +
[
'name' => "The magically disappearing contrib $type $extension",
'type' => $type,
];
if ($type === 'theme') {
$extension_info['base theme'] = FALSE;
}
$all_extensions_info[$extension] = $extension_info;
}
// Create the requirements test message.
if (!empty($extensions)) {
if (count($extensions) === 1) {
$test_error_texts[$type][] = "Missing or invalid {$type} "
. "The following {$type} is marked as installed in the core.extension configuration, but it is missing:"
. implode($extensions)
. $handbook_message;
}
else {
$test_error_texts[$type][] = "Missing or invalid {$type}s "
. "The following {$type}s are marked as installed in the core.extension configuration, but they are missing:"
. implode($extensions)
. $handbook_message;
}
$test_error_urls[$type][] = $error_url;
}
}
// Create the info.yml files for each extension.
foreach ($all_extensions_info as $machine_name => $extension_info) {
$type = $extension_info['type'];
$folder_path = \Drupal::getContainer()->getParameter('site.path') . "/{$type}s/contrib/$machine_name";
$file_path = "$folder_path/$machine_name.info.yml";
mkdir($folder_path, 0777, TRUE);
file_put_contents($file_path, Yaml::encode($extension_info));
$file_paths[$machine_name] = $file_path;
}
// Enable all the extensions.
foreach ($all_extensions_info as $machine_name => $extension_info) {
$extension_machine_names = [$machine_name];
$extension_names = [$extension_info['name']];
$this->enableExtensions($extension_info['type'], $extension_machine_names, $extension_names);
}
$folder_path = \Drupal::getContainer()->getParameter('site.path') . "/{$extension_type}s/$extension_machine_name";
$file_path = "$folder_path/$extension_machine_name.info.yml";
mkdir($folder_path, 0777, TRUE);
file_put_contents($file_path, Yaml::encode($extension_info));
$this->enableExtension($extension_type, $extension_machine_name, $extension_name);
// If there are no requirements warnings or errors, we expect to be able to
// go through the update process uninterrupted.
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
$this->drupalGet($this->statusReportUrl);
$types = ['module', 'theme'];
foreach ($types as $type) {
$all = array_merge($core[$type], $contrib[$type]);
$this->assertUpdateWithNoErrors($test_error_texts[$type], $type, $all);
}
// Delete the info.yml and confirm updates are prevented.
unlink($file_path);
$this->assertErrorOnUpdate($test_error_text, $extension_type, $extension_machine_name);
// Delete the info.yml(s) and confirm updates are prevented.
foreach ($file_paths as $file_path) {
unlink($file_path);
}
$this->drupalGet($this->statusReportUrl);
foreach ($types as $type) {
$all = array_merge($core[$type], $contrib[$type]);
$this->assertErrorOnUpdates($test_error_texts[$type], $type, $all, $test_error_urls[$type]);
}
// Add the info.yml file back and confirm we are able to go through the
// Add the info.yml file(s) back and confirm we are able to go through the
// update process uninterrupted.
file_put_contents($file_path, Yaml::encode($extension_info));
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
foreach ($all_extensions_info as $machine_name => $extension_info) {
file_put_contents($file_paths[$machine_name], Yaml::encode($extension_info));
}
$this->drupalGet($this->statusReportUrl);
foreach ($types as $type) {
$all = array_merge($core[$type], $contrib[$type]);
$this->assertUpdateWithNoErrors($test_error_texts[$type], $type, $all);
}
}
/**
@ -427,12 +543,44 @@ class UpdateScriptTest extends BrowserTestBase {
}
/**
* Data provider for testMissingExtension().
* Data provider for ::testMissingExtension().
*
* @return array[]
* Set of testcases to pass to the test method.
*/
public function providerMissingExtension() {
public function providerMissingExtension(): array {
return [
'module' => ['module'],
'theme' => ['theme'],
'core only' => [
'core' => [
'module' => ['aggregator'],
'theme' => ['seven'],
],
'contrib' => [
'module' => [],
'theme' => [],
],
],
'contrib only' => [
'core' => [
'module' => [],
'theme' => [],
],
'contrib' => [
'module' => ['module'],
'theme' => ['theme'],
],
],
'core and contrib' =>
[
'core' => [
'module' => ['aggregator', 'rdf'],
'theme' => ['seven'],
],
'contrib' => [
'module' => ['module_a', 'module_b'],
'theme' => ['theme_a', 'theme_b'],
],
],
];
}
@ -441,22 +589,55 @@ class UpdateScriptTest extends BrowserTestBase {
*
* @param string $extension_type
* The extension type.
* @param string $extension_machine_name
* The extension machine name.
* @param string $extension_name
* The extension name.
* @param array $extension_machine_names
* An array of the extension machine names.
* @param array $extension_names
* An array of extension names.
*/
protected function enableExtension($extension_type, $extension_machine_name, $extension_name) {
protected function enableExtensions(string $extension_type, array $extension_machine_names, array $extension_names): void {
if ($extension_type === 'module') {
$edit = [
"modules[$extension_machine_name][enable]" => $extension_machine_name,
];
$edit = [];
foreach ($extension_machine_names as $extension_machine_name) {
$edit["modules[$extension_machine_name][enable]"] = $extension_machine_name;
}
$this->drupalGet('admin/modules');
$this->submitForm($edit, 'Install');
}
elseif ($extension_type === 'theme') {
$this->drupalGet('admin/appearance');
$this->click("a[title~=\"$extension_name\"]");
foreach ($extension_names as $extension_name) {
$this->click("a[title~=\"$extension_name\"]");
}
}
}
/**
* Enables extensions the UI.
*
* @param array $extension_info
* An array of extension information arrays. The array is keyed by 'module'
* and 'theme'.
*/
protected function enableMissingExtensions(array $extension_info): void {
$edit = [];
foreach ($extension_info as $info) {
if ($info['type'] === 'module') {
$machine_name = $info['machine_name'];
$edit["modules[$machine_name][enable]"] = $machine_name;
}
if (!empty($edit)) {
$this->drupalGet('admin/modules');
$this->submitForm($edit, 'Install');
}
}
if (isset($extension_info['theme'])) {
$this->drupalGet('admin/appearance');
foreach ($extension_info as $info) {
if ($info['type' === 'theme']) {
$this->click('a[title~="' . $info['name'] . '"]');
}
}
}
}
@ -779,70 +960,83 @@ class UpdateScriptTest extends BrowserTestBase {
*
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param string $extension_machine_name
* The extension machine name.
* @param array $extension_machine_names
* An array of the extension machine names.
*
* @internal
*/
protected function assertInstalledExtensionConfig(string $extension_type, string $extension_machine_name): void {
protected function assertInstalledExtensionsConfig(string $extension_type, array $extension_machine_names): void {
$extension_config = $this->container->get('config.factory')->getEditable('core.extension');
$this->assertSame(0, $extension_config->get("$extension_type.$extension_machine_name"));
foreach ($extension_machine_names as $extension_machine_name) {
$this->assertSame(0, $extension_config->get("$extension_type.$extension_machine_name"));
}
}
/**
* Asserts a particular error is not shown on update and status report pages.
* Asserts particular errors are not shown on update and status report pages.
*
* @param string $unexpected_error_text
* The error text that should not be shown.
* @param array $unexpected_error_texts
* An array of the error texts that should not be shown.
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param string $extension_machine_name
* The extension machine name.
* @param array $extension_machine_names
* An array of the extension machine names.
*
* @throws \Behat\Mink\Exception\ResponseTextException
*
* @internal
*/
protected function assertUpdateWithNoError(string $unexpected_error_text, string $extension_type, string $extension_machine_name): void {
protected function assertUpdateWithNoErrors(array $unexpected_error_texts, string $extension_type, array $extension_machine_names): void {
$assert_session = $this->assertSession();
$this->drupalGet($this->statusReportUrl);
$this->assertSession()->pageTextNotContains($unexpected_error_text);
foreach ($unexpected_error_texts as $unexpected_error_text) {
$this->assertSession()->pageTextNotContains($unexpected_error_text);
}
$this->drupalGet($this->updateUrl, ['external' => TRUE]);
$this->assertSession()->pageTextNotContains($unexpected_error_text);
foreach ($unexpected_error_texts as $unexpected_error_text) {
$this->assertSession()->pageTextNotContains($unexpected_error_text);
}
$this->updateRequirementsProblem();
$this->clickLink('Continue');
$assert_session->pageTextContains('No pending updates.');
$this->assertInstalledExtensionConfig($extension_type, $extension_machine_name);
$this->assertInstalledExtensionsConfig($extension_type, $extension_machine_names);
}
/**
* Asserts an error is shown on the update and status report pages.
* Asserts errors are shown on the update and status report pages.
*
* @param string $expected_error_text
* The expected error text.
* @param array $expected_error_texts
* The expected error texts.
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param string $extension_machine_name
* The extension machine name.
* @param array $extension_machine_names
* The extension machine names.
* @param array $test_error_urls
* The URLs in the error texts.
*
* @throws \Behat\Mink\Exception\ExpectationException
* @throws \Behat\Mink\Exception\ResponseTextException
*
* @internal
*/
protected function assertErrorOnUpdate(string $expected_error_text, string $extension_type, string $extension_machine_name): void {
protected function assertErrorOnUpdates(array $expected_error_texts, string $extension_type, array $extension_machine_names, array $test_error_urls): void {
$assert_session = $this->assertSession();
$this->drupalGet($this->statusReportUrl);
$this->assertSession()->pageTextContains($expected_error_text);
foreach ($expected_error_texts as $expected_error_text) {
$this->assertSession()->pageTextContains($expected_error_text);
}
foreach ($test_error_urls as $test_error_url) {
$this->assertSession()->linkByHrefExists($test_error_url);
}
// Reload the update page to ensure the extension with the breaking values
// has not been uninstalled or otherwise affected.
for ($reload = 0; $reload <= 1; $reload++) {
$this->drupalGet($this->updateUrl, ['external' => TRUE]);
$this->assertSession()->pageTextContains($expected_error_text);
foreach ($expected_error_texts as $expected_error_text) {
$this->assertSession()->pageTextContains($expected_error_text);
}
$assert_session->linkNotExists('Continue');
}
$this->assertInstalledExtensionConfig($extension_type, $extension_machine_name);
$this->assertInstalledExtensionsConfig($extension_type, $extension_machine_names);
}
}