From b015ba779f9711938a262544cbd11883f8e280f0 Mon Sep 17 00:00:00 2001 From: catch Date: Wed, 28 Aug 2019 17:15:37 +0100 Subject: [PATCH] =?UTF-8?q?Issue=20#2313917=20by=20tedbow,=20pwolanin,=20j?= =?UTF-8?q?hedstrom,=20Wim=20Leers,=20Mixologic,=20larowlan,=20Mile23,=20G?= =?UTF-8?q?=C3=A1bor=20Hojtsy,=20xjm,=20Berdir:=20Core=20version=20key=20i?= =?UTF-8?q?n=20module's=20.info.yml=20doesn't=20respect=20core=20semantic?= =?UTF-8?q?=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/includes/update.inc | 3 +- .../Core/Extension/InfoParserDynamic.php | 143 +++++++- .../Drupal/Core/Extension/ModuleInstaller.php | 12 +- .../src/Controller/SystemController.php | 4 +- .../system/src/Form/ModulesListForm.php | 10 +- core/modules/system/system.admin.inc | 4 +- ...-8.update-test-semver-update-n-enabled.php | 38 +++ ...tem_core_incompatible_semver_test.info.yml | 6 + .../system_core_semver_test.info.yml | 6 + ...incompatible_core_version_test_1x.info.yml | 6 + .../modules/system_test/system_test.module | 1 + .../update_test_semver_update_n.info.yml | 6 + .../update_test_semver_update_n.install | 13 + .../Form/ModulesListFormWebTest.php | 2 +- .../src/Functional/Module/DependencyTest.php | 23 ++ .../tests/src/Functional/System/ThemeTest.php | 53 ++- .../test_core_semver.info.yml | 5 + .../test_invalid_core_semver.info.yml | 5 + core/modules/update/update.module | 4 +- .../Update/UpdatePathTestBaseTest.php | 4 + .../Core/Extension/ModuleInstallerTest.php | 57 ++++ .../Core/Extension/InfoParserUnitTest.php | 321 +++++++++++++++++- core/tests/Drupal/Tests/WebAssert.php | 29 ++ 23 files changed, 713 insertions(+), 42 deletions(-) create mode 100644 core/modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php create mode 100644 core/modules/system/tests/modules/system_core_incompatible_semver_test/system_core_incompatible_semver_test.info.yml create mode 100644 core/modules/system/tests/modules/system_core_semver_test/system_core_semver_test.info.yml create mode 100644 core/modules/system/tests/modules/system_incompatible_core_version_test_1x/system_incompatible_core_version_test_1x.info.yml create mode 100644 core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.info.yml create mode 100644 core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.install create mode 100644 core/modules/system/tests/themes/test_core_semver/test_core_semver.info.yml create mode 100644 core/modules/system/tests/themes/test_invalid_core_semver/test_invalid_core_semver.info.yml diff --git a/core/includes/update.inc b/core/includes/update.inc index 2021a662314..1299044427b 100644 --- a/core/includes/update.inc +++ b/core/includes/update.inc @@ -58,8 +58,7 @@ function update_check_incompatibility($name, $type = 'module') { $file = $themes[$name]; } if (!isset($file) - || !isset($file->info['core']) - || $file->info['core'] != \Drupal::CORE_COMPATIBILITY + || $file->info['core_incompatible'] || version_compare(phpversion(), $file->info['php']) < 0) { return TRUE; } diff --git a/core/lib/Drupal/Core/Extension/InfoParserDynamic.php b/core/lib/Drupal/Core/Extension/InfoParserDynamic.php index 9eb0a56e24d..b255c02a01e 100644 --- a/core/lib/Drupal/Core/Extension/InfoParserDynamic.php +++ b/core/lib/Drupal/Core/Extension/InfoParserDynamic.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Extension; +use Composer\Semver\Semver; use Drupal\Component\Serialization\Exception\InvalidDataTypeException; use Drupal\Core\Serialization\Yaml; @@ -10,6 +11,34 @@ use Drupal\Core\Serialization\Yaml; */ class InfoParserDynamic implements InfoParserInterface { + /** + * The earliest Drupal version that supports the 'core_version_requirement'. + */ + const FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION = '8.7.7'; + + /** + * Determines if a version satisfies the given constraints. + * + * This method uses \Composer\Semver\Semver::satisfies() but returns FALSE if + * the version or constraints are not valid instead of throwing an exception. + * + * @param string $version + * The version. + * @param string $constraints + * The constraints. + * + * @return bool + * TRUE if the version satisfies the constraints, otherwise FALSE. + */ + protected static function satisfies($version, $constraints) { + try { + return Semver::satisfies($version, $constraints); + } + catch (\UnexpectedValueException $exception) { + return FALSE; + } + } + /** * {@inheritdoc} */ @@ -28,6 +57,45 @@ class InfoParserDynamic implements InfoParserInterface { if (!empty($missing_keys)) { throw new InfoParserException('Missing required keys (' . implode(', ', $missing_keys) . ') in ' . $filename); } + if ($parsed_info['type'] === 'profile' && isset($parsed_info['core_version_requirement'])) { + // @todo Support the 'core_version_requirement' key in profiles in + // https://www.drupal.org/node/3070401. + throw new InfoParserException("The 'core_version_requirement' key is not supported in profiles in $filename"); + } + if (!isset($parsed_info['core']) && !isset($parsed_info['core_version_requirement'])) { + throw new InfoParserException("The 'core' or the 'core_version_requirement' key must be present in " . $filename); + } + if (isset($parsed_info['core']) && !preg_match("/^\d\.x$/", $parsed_info['core'])) { + throw new InfoParserException("Invalid 'core' value \"{$parsed_info['core']}\" in " . $filename); + } + if (isset($parsed_info['core_version_requirement'])) { + $supports_pre_core_version_requirement_version = static::isConstraintSatisfiedByPreviousVersion($parsed_info['core_version_requirement'], static::FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION); + // If the 'core_version_requirement' constraint does not satisfy any + // Drupal 8 versions before 8.7.7 then 'core' cannot be set or it will + // effectively support all versions of Drupal 8 because + // 'core_version_requirement' will be ignored in previous versions. + if (!$supports_pre_core_version_requirement_version && isset($parsed_info['core'])) { + throw new InfoParserException("The 'core_version_requirement' constraint ({$parsed_info['core_version_requirement']}) requires the 'core' key not be set in " . $filename); + } + // 'core_version_requirement' can not be used to specify Drupal 8 + // versions before 8.7.7 because these versions do not use the + // 'core_version_requirement' key. Do not throw the exception if the + // constraint also is satisfied by 8.0.0-alpha1 to allow constraints + // such as '^8' or '^8 || ^9'. + if ($supports_pre_core_version_requirement_version && !static::satisfies('8.0.0-alpha1', $parsed_info['core_version_requirement'])) { + throw new InfoParserException("The 'core_version_requirement' can not be used to specify compatibility for a specific version before " . static::FIRST_CORE_VERSION_REQUIREMENT_SUPPORTED_VERSION . " in $filename"); + } + } + + // Determine if the extension is compatible with the current version of + // Drupal core. + try { + $core_version_constraint = isset($parsed_info['core_version_requirement']) ? $parsed_info['core_version_requirement'] : $parsed_info['core']; + $parsed_info['core_incompatible'] = !static::satisfies(\Drupal::VERSION, $core_version_constraint); + } + catch (\UnexpectedValueException $exception) { + $parsed_info['core_incompatible'] = TRUE; + } if (isset($parsed_info['version']) && $parsed_info['version'] === 'VERSION') { $parsed_info['version'] = \Drupal::VERSION; } @@ -60,7 +128,80 @@ class InfoParserDynamic implements InfoParserInterface { * An array of required keys. */ protected function getRequiredKeys() { - return ['type', 'core', 'name']; + return ['type', 'name']; + } + + /** + * Determines if a constraint is satisfied by earlier versions of Drupal 8. + * + * @param string $constraint + * A core semantic version constraint. + * @param string $version + * A core version. + * + * @return bool + * TRUE if the constraint is satisfied by a core version prior to the + * provided version. + */ + protected static function isConstraintSatisfiedByPreviousVersion($constraint, $version) { + static $evaluated_constraints = []; + // Any particular constraint and version combination only needs to be + // evaluated once. + if (!isset($evaluated_constraints[$constraint][$version])) { + $evaluated_constraints[$constraint][$version] = FALSE; + foreach (static::getAllPreviousCoreVersions($version) as $previous_version) { + if (static::satisfies($previous_version, $constraint)) { + $evaluated_constraints[$constraint][$version] = TRUE; + // The constraint only has to satisfy one previous version so break + // when the first one is found. + break; + } + } + } + return $evaluated_constraints[$constraint][$version]; + } + + /** + * Gets all the versions of Drupal 8 before a specific version. + * + * @param string $version + * The version to get versions before. + * + * @return array + * All of the applicable Drupal 8 releases. + */ + protected static function getAllPreviousCoreVersions($version) { + static $versions_lists = []; + // Check if list of previous versions for the specified version has already + // been created. + if (empty($versions_lists[$version])) { + // Loop through all minor versions including 8.7. + foreach (range(0, 7) as $minor) { + // The largest patch number in a release was 17 in 8.6.17. Use 27 to + // leave room for future security releases. + foreach (range(0, 27) as $patch) { + $patch_version = "8.$minor.$patch"; + if ($patch_version === $version) { + // Reverse the order of the versions so that they will be evaluated + // from the most recent versions first. + $versions_lists[$version] = array_reverse($versions_lists[$version]); + return $versions_lists[$version]; + } + if ($patch === 0) { + // If this is a '0' patch release like '8.1.0' first create the + // pre-release versions such as '8.1.0-alpha1' and '8.1.0-rc1'. + foreach (['alpha', 'beta', 'rc'] as $prerelease) { + // The largest prerelease number was in 8.0.0-beta16. + foreach (range(0, 16) as $prerelease_number) { + $versions_lists[$version][] = "$patch_version-$prerelease$prerelease_number"; + } + } + } + $versions_lists[$version][] = $patch_version; + } + } + } + return $versions_lists[$version]; } } diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 86cc9bc44de..c28f71f8e2a 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -81,9 +81,14 @@ class ModuleInstaller implements ModuleInstallerInterface { */ public function install(array $module_list, $enable_dependencies = TRUE) { $extension_config = \Drupal::configFactory()->getEditable('core.extension'); + // Get all module data so we can find dependencies and sort. + $module_data = system_rebuild_module_data(); + foreach ($module_list as $module) { + if (!empty($module_data[$module]->info['core_incompatible'])) { + throw new MissingDependencyException("Unable to install modules: module '$module' is incompatible with this version of Drupal core."); + } + } if ($enable_dependencies) { - // Get all module data so we can find dependencies and sort. - $module_data = system_rebuild_module_data(); $module_list = $module_list ? array_combine($module_list, $module_list) : []; if ($missing_modules = array_diff_key($module_list, $module_data)) { // One or more of the given modules doesn't exist. @@ -108,6 +113,9 @@ class ModuleInstaller implements ModuleInstallerInterface { // Skip already installed modules. if (!isset($module_list[$dependency]) && !isset($installed_modules[$dependency])) { + if ($module_data[$dependency]->info['core_incompatible']) { + throw new MissingDependencyException("Unable to install modules: module '$module'. Its dependency module '$dependency' is incompatible with this version of Drupal core."); + } $module_list[$dependency] = $dependency; } } diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index 106f4e29154..e946261ba55 100644 --- a/core/modules/system/src/Controller/SystemController.php +++ b/core/modules/system/src/Controller/SystemController.php @@ -222,8 +222,6 @@ class SystemController extends ControllerBase { } if (empty($theme->status)) { - // Ensure this theme is compatible with this version of core. - $theme->incompatible_core = !isset($theme->info['core']) || ($theme->info['core'] != \DRUPAL::CORE_COMPATIBILITY); // Require the 'content' region to make sure the main page // content has a common place in all themes. $theme->incompatible_region = !isset($theme->info['regions']['content']); @@ -234,7 +232,7 @@ class SystemController extends ControllerBase { $theme->incompatible_engine = isset($theme->info['engine']) && !isset($theme->owner); } $theme->operations = []; - if (!empty($theme->status) || !$theme->incompatible_core && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) { + if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) { // Create the operations links. $query['theme'] = $theme->getName(); if ($this->themeAccess->checkAccess($theme->getName())) { diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php index c2ade11b7a6..c8a1b8f4b53 100644 --- a/core/modules/system/src/Form/ModulesListForm.php +++ b/core/modules/system/src/Form/ModulesListForm.php @@ -281,10 +281,14 @@ class ModulesListForm extends FormBase { $reasons = []; // Check the core compatibility. - if ($module->info['core'] != \Drupal::CORE_COMPATIBILITY) { + if ($module->info['core_incompatible']) { $compatible = FALSE; $reasons[] = $this->t('This version is not compatible with Drupal @core_version and should be replaced.', [ - '@core_version' => \Drupal::CORE_COMPATIBILITY, + '@core_version' => \Drupal::VERSION, + ]); + $row['#requires']['core'] = $this->t('Drupal Core (@core_requirement) (incompatible with version @core_version)', [ + '@core_requirement' => isset($module->info['core_version_requirement']) ? $module->info['core_version_requirement'] : $module->info['core'], + '@core_version' => \Drupal::VERSION, ]); } @@ -328,7 +332,7 @@ class ModulesListForm extends FormBase { } // Disable the checkbox if the dependency is incompatible with this // version of Drupal core. - elseif ($modules[$dependency]->info['core'] != \Drupal::CORE_COMPATIBILITY) { + elseif ($modules[$dependency]->info['core_incompatible']) { $row['#requires'][$dependency] = $this->t('@module (incompatible with this version of Drupal core)', [ '@module' => $name, ]); diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc index 799869bbc37..eea18b0ec9a 100644 --- a/core/modules/system/system.admin.inc +++ b/core/modules/system/system.admin.inc @@ -292,8 +292,8 @@ function template_preprocess_system_themes_page(&$variables) { // Make sure to provide feedback on compatibility. $current_theme['incompatible'] = ''; - if (!empty($theme->incompatible_core)) { - $current_theme['incompatible'] = t("This theme is not compatible with Drupal @core_version. Check that the .info.yml file contains the correct 'core' value.", ['@core_version' => \Drupal::CORE_COMPATIBILITY]); + if (!empty($theme->info['core_incompatible'])) { + $current_theme['incompatible'] = t("This theme is not compatible with Drupal @core_version. Check that the .info.yml file contains a compatible 'core' or 'core_version_requirement' value.", ['@core_version' => \Drupal::VERSION]); } elseif (!empty($theme->incompatible_region)) { $current_theme['incompatible'] = t("This theme is missing a 'content' region."); diff --git a/core/modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php b/core/modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php new file mode 100644 index 00000000000..285b42c1ef4 --- /dev/null +++ b/core/modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php @@ -0,0 +1,38 @@ +merge('key_value') + ->condition('collection', 'system.schema') + ->condition('name', 'update_test_semver_update_n') + ->fields([ + 'collection' => 'system.schema', + 'name' => 'update_test_semver_update_n', + 'value' => 'i:8000;', + ]) + ->execute(); + +// Update core.extension. +$extensions = $connection->select('config') + ->fields('config', ['data']) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute() + ->fetchField(); +$extensions = unserialize($extensions); +$extensions['module']['update_test_semver_update_n'] = 8000; +$connection->update('config') + ->fields([ + 'data' => serialize($extensions), + ]) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute(); diff --git a/core/modules/system/tests/modules/system_core_incompatible_semver_test/system_core_incompatible_semver_test.info.yml b/core/modules/system/tests/modules/system_core_incompatible_semver_test/system_core_incompatible_semver_test.info.yml new file mode 100644 index 00000000000..152d2a66ec1 --- /dev/null +++ b/core/modules/system/tests/modules/system_core_incompatible_semver_test/system_core_incompatible_semver_test.info.yml @@ -0,0 +1,6 @@ +name: 'System core incompatible semver test' +type: module +description: 'Support module for testing core incompatible semver.' +package: Testing +version: 1.0.0 +core_version_requirement: ^7 diff --git a/core/modules/system/tests/modules/system_core_semver_test/system_core_semver_test.info.yml b/core/modules/system/tests/modules/system_core_semver_test/system_core_semver_test.info.yml new file mode 100644 index 00000000000..47eec87f489 --- /dev/null +++ b/core/modules/system/tests/modules/system_core_semver_test/system_core_semver_test.info.yml @@ -0,0 +1,6 @@ +name: 'System core ^8 version test' +type: module +description: 'Support module for testing core using semver.' +package: Testing +version: 1.0.0 +core_version_requirement: ^8 diff --git a/core/modules/system/tests/modules/system_incompatible_core_version_test_1x/system_incompatible_core_version_test_1x.info.yml b/core/modules/system/tests/modules/system_incompatible_core_version_test_1x/system_incompatible_core_version_test_1x.info.yml new file mode 100644 index 00000000000..f79d71dff85 --- /dev/null +++ b/core/modules/system/tests/modules/system_incompatible_core_version_test_1x/system_incompatible_core_version_test_1x.info.yml @@ -0,0 +1,6 @@ +name: 'System incompatible core 1.x version test' +type: module +description: 'Support module for testing system core incompatibility.' +package: Testing +version: 1.0.0 +core: 1.x diff --git a/core/modules/system/tests/modules/system_test/system_test.module b/core/modules/system/tests/modules/system_test/system_test.module index affc20ea420..7f0ba88cd03 100644 --- a/core/modules/system/tests/modules/system_test/system_test.module +++ b/core/modules/system/tests/modules/system_test/system_test.module @@ -71,6 +71,7 @@ function system_test_system_info_alter(&$info, Extension $file, $type) { 'system_incompatible_core_version_dependencies_test', 'system_incompatible_module_version_test', 'system_incompatible_core_version_test', + 'system_incompatible_core_version_test_1x', ])) { $info['hidden'] = FALSE; } diff --git a/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.info.yml b/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.info.yml new file mode 100644 index 00000000000..74e908530d6 --- /dev/null +++ b/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.info.yml @@ -0,0 +1,6 @@ +name: 'Update test hook_update_n semver' +type: module +description: 'Support module for update testing with core semver value.' +package: Testing +version: VERSION +core_version_requirement: ^8 diff --git a/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.install b/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.install new file mode 100644 index 00000000000..aacb595710c --- /dev/null +++ b/core/modules/system/tests/modules/update_test_semver_update_n/update_test_semver_update_n.install @@ -0,0 +1,13 @@ +set('update_test_semver_update_n_update_8001', 'Yes, I was run. Thanks for testing!'); +} diff --git a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php index 52e4160cda9..1e1b2fc579f 100644 --- a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php +++ b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php @@ -70,7 +70,7 @@ BROKEN; // Confirm that the error message is shown. $this->assertSession() - ->pageTextContains('Modules could not be listed due to an error: Missing required keys (core) in ' . $path . '/broken.info.yml'); + ->pageTextContains("The 'core' or the 'core_version_requirement' key must be present in " . $path . '/broken.info.yml'); // Check that the module filter text box is available. $this->assertTrue($this->xpath('//input[@name="text"]')); diff --git a/core/modules/system/tests/src/Functional/Module/DependencyTest.php b/core/modules/system/tests/src/Functional/Module/DependencyTest.php index d2938721a86..5fb18d7db5a 100644 --- a/core/modules/system/tests/src/Functional/Module/DependencyTest.php +++ b/core/modules/system/tests/src/Functional/Module/DependencyTest.php @@ -102,6 +102,29 @@ class DependencyTest extends ModuleTestBase { $this->assert(count($checkbox) == 1, 'Checkbox for the module is disabled.'); } + /** + * Tests enabling modules with different core version specifications. + */ + public function testCoreCompatibility() { + $assert_session = $this->assertSession(); + + // Test incompatible 'core_version_requirement'. + $this->drupalGet('admin/modules'); + $assert_session->fieldDisabled('modules[system_incompatible_core_version_test_1x][enable]'); + $assert_session->fieldDisabled('modules[system_core_incompatible_semver_test][enable]'); + + // Test compatible 'core_version_requirement' and compatible 'core'. + $this->drupalGet('admin/modules'); + $assert_session->fieldEnabled('modules[common_test][enable]'); + $assert_session->fieldEnabled('modules[system_core_semver_test][enable]'); + + // Ensure the modules can actually be installed. + $edit['modules[common_test][enable]'] = 'common_test'; + $edit['modules[system_core_semver_test][enable]'] = 'system_core_semver_test'; + $this->drupalPostForm('admin/modules', $edit, t('Install')); + $this->assertModules(['common_test', 'system_core_semver_test'], TRUE); + } + /** * Tests enabling a module that depends on a module which fails hook_requirements(). */ diff --git a/core/modules/system/tests/src/Functional/System/ThemeTest.php b/core/modules/system/tests/src/Functional/System/ThemeTest.php index 1c69d7a3b6a..e6e4675a37e 100644 --- a/core/modules/system/tests/src/Functional/System/ThemeTest.php +++ b/core/modules/system/tests/src/Functional/System/ThemeTest.php @@ -366,8 +366,11 @@ class ThemeTest extends BrowserTestBase { $this->assertText(t('This theme requires the base theme @base_theme to operate correctly.', ['@base_theme' => 'not_real_test_basetheme'])); $this->assertText(t('This theme requires the base theme @base_theme to operate correctly.', ['@base_theme' => 'test_invalid_basetheme'])); $this->assertText(t('This theme requires the theme engine @theme_engine to operate correctly.', ['@theme_engine' => 'not_real_engine'])); - // Check for the error text of a theme with the wrong core version. - $this->assertText("This theme is not compatible with Drupal 8.x. Check that the .info.yml file contains the correct 'core' value."); + // Check for the error text of a theme with the wrong core version + // using 7.x and ^7. + $incompatible_core_message = 'This theme is not compatible with Drupal ' . \Drupal::VERSION . ". Check that the .info.yml file contains a compatible 'core' or 'core_version_requirement' value."; + $this->assertThemeIncompatibleText('Theme test with invalid core version', $incompatible_core_message); + $this->assertThemeIncompatibleText('Theme test with invalid semver core version', $incompatible_core_message); // Check for the error text of a theme without a content region. $this->assertText("This theme is missing a 'content' region."); } @@ -436,24 +439,28 @@ class ThemeTest extends BrowserTestBase { * Tests installing a theme and setting it as default. */ public function testInstallAndSetAsDefault() { - $this->drupalGet('admin/appearance'); - // Bartik is uninstalled in the test profile and has the third "Install and - // set as default" link. - $this->clickLink(t('Install and set as default'), 2); - // Test the confirmation message. - $this->assertText('Bartik is now the default theme.'); - // Make sure Bartik is now set as the default theme in config. - $this->assertEqual($this->config('system.theme')->get('default'), 'bartik'); + $themes = [ + 'bartik' => 'Bartik', + 'test_core_semver' => 'Theme test with semver core version', + ]; + foreach ($themes as $theme_machine_name => $theme_name) { + $this->drupalGet('admin/appearance'); + $this->getSession()->getPage()->findLink("Install $theme_name as default theme")->click(); + // Test the confirmation message. + $this->assertText("$theme_name is now the default theme."); + // Make sure the theme is now set as the default theme in config. + $this->assertEqual($this->config('system.theme')->get('default'), $theme_machine_name); - // This checks for a regression. See https://www.drupal.org/node/2498691. - $this->assertNoText('The bartik theme was not found.'); + // This checks for a regression. See https://www.drupal.org/node/2498691. + $this->assertNoText("The $theme_machine_name theme was not found."); - $themes = \Drupal::service('theme_handler')->rebuildThemeData(); - $version = $themes['bartik']->info['version']; + $themes = \Drupal::service('theme_handler')->rebuildThemeData(); + $version = $themes[$theme_machine_name]->info['version']; - // Confirm Bartik is indicated as the default theme. - $out = $this->getSession()->getPage()->getContent(); - $this->assertTrue((bool) preg_match('/Bartik ' . preg_quote($version) . '\s{2,}\(default theme\)/', $out)); + // Confirm the theme is indicated as the default theme. + $out = $this->getSession()->getPage()->getContent(); + $this->assertTrue((bool) preg_match("/$theme_name " . preg_quote($version) . '\s{2,}\(default theme\)/', $out)); + } } /** @@ -469,4 +476,16 @@ class ThemeTest extends BrowserTestBase { $this->assertText('The configuration options have been saved.'); } + /** + * Asserts that expected incompatibility text is displayed for a theme. + * + * @param string $theme_name + * Theme name to select element on page. This can be a partial name. + * @param string $expected_text + * The expected incompatibility text. + */ + private function assertThemeIncompatibleText($theme_name, $expected_text) { + $this->assertSession()->elementExists('css', ".theme-info:contains(\"$theme_name\") .incompatible:contains(\"$expected_text\")"); + } + } diff --git a/core/modules/system/tests/themes/test_core_semver/test_core_semver.info.yml b/core/modules/system/tests/themes/test_core_semver/test_core_semver.info.yml new file mode 100644 index 00000000000..4645badc2ac --- /dev/null +++ b/core/modules/system/tests/themes/test_core_semver/test_core_semver.info.yml @@ -0,0 +1,5 @@ +name: 'Theme test with semver core version' +type: theme +description: 'Test theme which has semver core version.' +version: VERSION +core_version_requirement: ^8 || ^9 diff --git a/core/modules/system/tests/themes/test_invalid_core_semver/test_invalid_core_semver.info.yml b/core/modules/system/tests/themes/test_invalid_core_semver/test_invalid_core_semver.info.yml new file mode 100644 index 00000000000..a1e1c5a8272 --- /dev/null +++ b/core/modules/system/tests/themes/test_invalid_core_semver/test_invalid_core_semver.info.yml @@ -0,0 +1,5 @@ +name: 'Theme test with invalid semver core version' +type: theme +description: 'Test theme which has an invalid semver core version.' +version: VERSION +core_version_requirement: ^7 diff --git a/core/modules/update/update.module b/core/modules/update/update.module index c783d7e82bb..8823b783be0 100644 --- a/core/modules/update/update.module +++ b/core/modules/update/update.module @@ -696,7 +696,7 @@ function update_verify_update_archive($project, $archive_file, $directory) { $info = \Drupal::service('info_parser')->parse($file->uri); // If the module or theme is incompatible with Drupal core, set an error. - if (empty($info['core']) || $info['core'] != \Drupal::CORE_COMPATIBILITY) { + if ($info['core_incompatible']) { $incompatible[] = !empty($info['name']) ? $info['name'] : t('Unknown'); } else { @@ -716,7 +716,7 @@ function update_verify_update_archive($project, $archive_file, $directory) { '%archive_file contains a version of %names that is not compatible with Drupal @version.', '%archive_file contains versions of modules or themes that are not compatible with Drupal @version: %names', [ - '@version' => \Drupal::CORE_COMPATIBILITY, + '@version' => \Drupal::VERSION, '%archive_file' => $file_system->basename($archive_file), '%names' => implode(', ', $incompatible), ] diff --git a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBaseTest.php b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBaseTest.php index 942cb886ad2..504eb96ab96 100644 --- a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBaseTest.php +++ b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBaseTest.php @@ -26,6 +26,7 @@ class UpdatePathTestBaseTest extends UpdatePathTestBase { $this->databaseDumpFiles = [ __DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.bare.standard.php.gz', __DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.update-test-schema-enabled.php', + __DIR__ . '/../../../../modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php', ]; } @@ -99,8 +100,11 @@ class UpdatePathTestBaseTest extends UpdatePathTestBase { // Ensure schema has changed. $this->assertEqual(drupal_get_installed_schema_version('update_test_schema', TRUE), 8001); + $this->assertEqual(drupal_get_installed_schema_version('update_test_semver_update_n', TRUE), 8001); // Ensure the index was added for column a. $this->assertTrue($connection->schema()->indexExists('update_test_schema_table', 'test'), 'Version 8001 of the update_test_schema module is installed.'); + // Ensure update_test_semver_update_n_update_8001 was run. + $this->assertEquals(\Drupal::state()->get('update_test_semver_update_n_update_8001'), 'Yes, I was run. Thanks for testing!'); } /** diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ModuleInstallerTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ModuleInstallerTest.php index 7ffe65c9fc3..529de644a5a 100644 --- a/core/tests/Drupal/KernelTests/Core/Extension/ModuleInstallerTest.php +++ b/core/tests/Drupal/KernelTests/Core/Extension/ModuleInstallerTest.php @@ -3,6 +3,7 @@ namespace Drupal\KernelTests\Core\Extension; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\MissingDependencyException; use Drupal\KernelTests\KernelTestBase; use Symfony\Component\Routing\Exception\RouteNotFoundException; @@ -94,4 +95,60 @@ class ModuleInstallerTest extends KernelTestBase { $this->assertTrue($module_installer->install(['module_test'])); } + /** + * Tests install with a module with an invalid core version constraint. + * + * @dataProvider providerTestInvalidCoreInstall + * @covers ::install + */ + public function testInvalidCoreInstall($module_name, $install_dependencies) { + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessage("Unable to install modules: module '$module_name' is incompatible with this version of Drupal core."); + $this->container->get('module_installer')->install([$module_name], $install_dependencies); + } + + /** + * Dataprovider for testInvalidCoreInstall(). + */ + public function providerTestInvalidCoreInstall() { + return [ + 'no dependencies system_incompatible_core_version_test_1x' => [ + 'system_incompatible_core_version_test_1x', + FALSE, + ], + 'install_dependencies system_incompatible_core_version_test_1x' => [ + 'system_incompatible_core_version_test_1x', + TRUE, + ], + 'no dependencies system_core_incompatible_semver_test' => [ + 'system_core_incompatible_semver_test', + FALSE, + ], + 'install_dependencies system_core_incompatible_semver_test' => [ + 'system_core_incompatible_semver_test', + TRUE, + ], + ]; + } + + /** + * Tests install with a dependency with an invalid core version constraint. + * + * @covers ::install + */ + public function testDependencyInvalidCoreInstall() { + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessage("Unable to install modules: module 'system_incompatible_core_version_dependencies_test'. Its dependency module 'system_incompatible_core_version_test' is incompatible with this version of Drupal core."); + $this->container->get('module_installer')->install(['system_incompatible_core_version_dependencies_test']); + } + + /** + * Tests no dependencies install with a dependency with invalid core. + * + * @covers ::install + */ + public function testDependencyInvalidCoreInstallNoDependencies() { + $this->assertTrue($this->container->get('module_installer')->install(['system_incompatible_core_version_dependencies_test'], FALSE)); + } + } diff --git a/core/tests/Drupal/Tests/Core/Extension/InfoParserUnitTest.php b/core/tests/Drupal/Tests/Core/Extension/InfoParserUnitTest.php index 796a916db9b..0a136b05a5c 100644 --- a/core/tests/Drupal/Tests/Core/Extension/InfoParserUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/InfoParserUnitTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Core\Extension; use Drupal\Core\Extension\InfoParser; +use Drupal\Core\Extension\InfoParserException; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; @@ -96,10 +97,212 @@ MISSINGKEYS; ], ]); $filename = vfsStream::url('modules/fixtures/missing_keys.info.txt'); - $this->setExpectedException('\Drupal\Core\Extension\InfoParserException', 'Missing required keys (type, core, name) in vfs://modules/fixtures/missing_keys.info.txt'); + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage('Missing required keys (type, name) in vfs://modules/fixtures/missing_keys.info.txt'); $this->infoParser->parse($filename); } + /** + * Tests that missing 'core' and 'core_version_requirement' keys are detected. + * + * @covers ::parse + */ + public function testMissingCoreCoreVersionRequirement() { + $missing_core_and_core_version_requirement = << [ + 'missing_core_and_core_version_requirement.info.txt' => $missing_core_and_core_version_requirement, + 'missing_core_and_core_version_requirement-duplicate.info.txt' => $missing_core_and_core_version_requirement, + ], + ]); + $exception_message = "The 'core' or the 'core_version_requirement' key must be present in vfs://modules/fixtures/missing_core_and_core_version_requirement"; + // Set the expected exception for the 2nd call to parse(). + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage("$exception_message-duplicate.info.txt"); + + try { + $this->infoParser->parse(vfsStream::url('modules/fixtures/missing_core_and_core_version_requirement.info.txt')); + } + catch (InfoParserException $exception) { + $this->assertSame("$exception_message.info.txt", $exception->getMessage()); + + $this->infoParser->parse(vfsStream::url('modules/fixtures/missing_core_and_core_version_requirement-duplicate.info.txt')); + } + } + + /** + * Tests that 'core_version_requirement: ^8.8' is valid with no 'core' key. + * + * @covers ::parse + */ + public function testCoreVersionRequirement88() { + $core_version_requirement = << [ + $filename => $core_version_requirement, + ], + ]); + $info_values = $this->infoParser->parse(vfsStream::url("modules/fixtures/$filename")); + $this->assertSame($info_values['core_version_requirement'], '^8.8', "Expected core_version_requirement for file: $filename"); + } + } + + /** + * Tests that 'core_version_requirement: ^8.8' is invalid with a 'core' key. + * + * @covers ::parse + */ + public function testCoreCoreVersionRequirement88() { + $core_and_core_version_requirement_88 = << [ + 'core_and_core_version_requirement_88.info.txt' => $core_and_core_version_requirement_88, + 'core_and_core_version_requirement_88-duplicate.info.txt' => $core_and_core_version_requirement_88, + ], + ]); + $exception_message = "The 'core_version_requirement' constraint (^8.8) requires the 'core' key not be set in vfs://modules/fixtures/core_and_core_version_requirement_88"; + // Set the expected exception for the 2nd call to parse(). + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage("$exception_message-duplicate.info.txt"); + try { + $this->infoParser->parse(vfsStream::url('modules/fixtures/core_and_core_version_requirement_88.info.txt')); + } + catch (InfoParserException $exception) { + $this->assertSame("$exception_message.info.txt", $exception->getMessage()); + + $this->infoParser->parse(vfsStream::url('modules/fixtures/core_and_core_version_requirement_88-duplicate.info.txt')); + } + } + + /** + * Tests a invalid 'core' key. + * + * @covers ::parse + */ + public function testInvalidCore() { + $invalid_core = << [ + 'invalid_core.info.txt' => $invalid_core, + 'invalid_core-duplicate.info.txt' => $invalid_core, + ], + ]); + $exception_message = "Invalid 'core' value \"^8\" in vfs://modules/fixtures/invalid_core"; + // Set the expected exception for the 2nd call to parse(). + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage("$exception_message-duplicate.info.txt"); + + try { + $this->infoParser->parse(vfsStream::url('modules/fixtures/invalid_core.info.txt')); + } + catch (InfoParserException $exception) { + $this->assertSame("$exception_message.info.txt", $exception->getMessage()); + + $this->infoParser->parse(vfsStream::url('modules/fixtures/invalid_core-duplicate.info.txt')); + } + } + + /** + * Tests a invalid 'core_version_requirement'. + * + * @covers ::parse + * + * @dataProvider providerCoreVersionRequirementInvalid + */ + public function testCoreVersionRequirementInvalid($test_case, $constraint) { + $invalid_core_version_requirement = << [ + "invalid_core_version_requirement-$test_case.info.txt" => $invalid_core_version_requirement, + "invalid_core_version_requirement-$test_case-duplicate.info.txt" => $invalid_core_version_requirement, + ], + ]); + $exception_message = "The 'core_version_requirement' can not be used to specify compatibility for a specific version before 8.7.7 in vfs://modules/fixtures/invalid_core_version_requirement-$test_case"; + // Set the expected exception for the 2nd call to parse(). + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage("$exception_message-duplicate.info.txt"); + try { + $this->infoParser->parse(vfsStream::url("modules/fixtures/invalid_core_version_requirement-$test_case.info.txt")); + } + catch (InfoParserException $exception) { + $this->assertSame("$exception_message.info.txt", $exception->getMessage()); + + $this->infoParser->parse(vfsStream::url("modules/fixtures/invalid_core_version_requirement-$test_case-duplicate.info.txt")); + } + } + + /** + * Dataprovider for testCoreVersionRequirementInvalid(). + */ + public function providerCoreVersionRequirementInvalid() { + return [ + '8.0.0-alpha2' => ['alpha2', '8.0.0-alpha2'], + '8.6.0-rc1' => ['rc1', '8.6.0-rc1'], + '^8.7' => ['8_7', '^8.7'], + '>8.6.3' => ['gt8_6_3', '>8.6.3'], + ]; + } + /** * Tests that missing required key is detected. * @@ -121,11 +324,20 @@ MISSINGKEY; vfsStream::create([ 'fixtures' => [ 'missing_key.info.txt' => $missing_key, + 'missing_key-duplicate.info.txt' => $missing_key, ], ]); - $filename = vfsStream::url('modules/fixtures/missing_key.info.txt'); - $this->setExpectedException('\Drupal\Core\Extension\InfoParserException', 'Missing required keys (type) in vfs://modules/fixtures/missing_key.info.txt'); - $this->infoParser->parse($filename); + try { + $this->infoParser->parse(vfsStream::url('modules/fixtures/missing_key.info.txt')); + } + catch (InfoParserException $exception) { + $this->assertSame('Missing required keys (type) in vfs://modules/fixtures/missing_key.info.txt', $exception->getMessage()); + + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage('Missing required keys (type) in vfs://modules/fixtures/missing_key-duplicate.info.txt'); + $this->infoParser->parse(vfsStream::url('modules/fixtures/missing_key-duplicate.info.txt')); + } + } /** @@ -145,15 +357,106 @@ double_colon: dummyClassName::method COMMONTEST; vfsStream::setup('modules'); + + foreach (['1', '2'] as $file_delta) { + $filename = "common_test-$file_delta.info.txt"; + vfsStream::create([ + 'fixtures' => [ + $filename => $common, + ], + ]); + $info_values = $this->infoParser->parse(vfsStream::url("modules/fixtures/$filename")); + $this->assertEquals($info_values['simple_string'], 'A simple string', 'Simple string value was parsed correctly.'); + $this->assertEquals($info_values['version'], \Drupal::VERSION, 'Constant value was parsed correctly.'); + $this->assertEquals($info_values['double_colon'], 'dummyClassName::method', 'Value containing double-colon was parsed correctly.'); + $this->assertSame('8.x', $info_values['core']); + $this->assertFalse(isset($info_values['core_version_requirement'])); + $this->assertFalse($info_values['core_incompatible']); + } + } + + /** + * @covers ::parse + * + * @dataProvider providerCoreIncompatibility + */ + public function testCoreIncompatibility($test_case, $constraint, $expected) { + $core_incompatibility = << [ + $filename => $core_incompatibility, + ], + ]); + $info_values = $this->infoParser->parse(vfsStream::url("modules/fixtures/$filename")); + $this->assertSame($expected, $info_values['core_incompatible'], "core_incompatible correct in file: $filename"); + } + } + + /** + * Dataprovider for testCoreIncompatibility(). + */ + public function providerCoreIncompatibility() { + list($major, $minor) = explode('.', \Drupal::VERSION); + + $next_minor = $minor + 1; + $next_major = $major + 1; + return [ + 'next_minor' => [ + 'next_minor', + "^$major.$next_minor", + TRUE, + ], + 'current_major_next_major' => [ + 'current_major_next_major', + "^$major || ^$next_major", + FALSE, + ], + 'previous_major_next_major' => [ + 'previous_major_next_major', + "^1 || ^$next_major", + TRUE, + ], + 'invalid' => [ + 'invalid', + 'this-string-is-invalid', + TRUE, + ], + ]; + } + + /** + * Test a profile info file with the 'core_version_requirement' key. + */ + public function testInvalidProfile() { + $profile = << [ - 'common_test.info.txt' => $common, + 'invalid_profile.info.txt' => $profile, ], ]); - $info_values = $this->infoParser->parse(vfsStream::url('modules/fixtures/common_test.info.txt')); - $this->assertEquals($info_values['simple_string'], 'A simple string', 'Simple string value was parsed correctly.'); - $this->assertEquals($info_values['version'], \Drupal::VERSION, 'Constant value was parsed correctly.'); - $this->assertEquals($info_values['double_colon'], 'dummyClassName::method', 'Value containing double-colon was parsed correctly.'); + $this->expectException('\Drupal\Core\Extension\InfoParserException'); + $this->expectExceptionMessage("The 'core_version_requirement' key is not supported in profiles in vfs://profiles/fixtures/invalid_profile.info.txt"); + $this->infoParser->parse(vfsStream::url('profiles/fixtures/invalid_profile.info.txt')); } } diff --git a/core/tests/Drupal/Tests/WebAssert.php b/core/tests/Drupal/Tests/WebAssert.php index 4f7a7d292c5..f37cc70db7a 100644 --- a/core/tests/Drupal/Tests/WebAssert.php +++ b/core/tests/Drupal/Tests/WebAssert.php @@ -491,6 +491,35 @@ class WebAssert extends MinkWebAssert { return $node; } + /** + * Checks that a given form field element is enabled. + * + * @param string $field + * One of id|name|label|value for the field. + * @param \Behat\Mink\Element\TraversableElement $container + * (optional) The document to check against. Defaults to the current page. + * + * @return \Behat\Mink\Element\NodeElement + * The matching element. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + */ + public function fieldEnabled($field, TraversableElement $container = NULL) { + $container = $container ?: $this->session->getPage(); + $node = $container->findField($field); + + if ($node === NULL) { + throw new ElementNotFoundException($this->session->getDriver(), 'field', 'id|name|label|value', $field); + } + + if ($node->hasAttribute('disabled')) { + throw new ExpectationException("Field $field is not enabled", $this->session->getDriver()); + } + + return $node; + } + /** * Checks that specific hidden field exists. *