diff options
381 files changed, 9184 insertions, 3456 deletions
diff --git a/.gitlab-ci/pipeline.yml b/.gitlab-ci/pipeline.yml index c311b1a03a6..06f5c83acbc 100644 --- a/.gitlab-ci/pipeline.yml +++ b/.gitlab-ci/pipeline.yml @@ -104,7 +104,7 @@ variables: script: - sudo -u www-data -E -H composer run-script drupal-phpunit-upgrade-check # Need to pass this along directly. - - sudo -u www-data -E -H php ./core/scripts/run-tests.sh --color --keep-results --types "$TESTSUITE" --concurrency "$CONCURRENCY" --repeat "1" --sqlite "./sites/default/files/tests.sqlite" --dburl $SIMPLETEST_DB --url $SIMPLETEST_BASE_URL --verbose --non-html --all --ci-parallel-node-index $CI_PARALLEL_NODE_INDEX --ci-parallel-node-total $CI_PARALLEL_NODE_TOTAL + - sudo -u www-data -E -H php ./core/scripts/run-tests.sh --debug-discovery --color --keep-results --types "$TESTSUITE" --concurrency "$CONCURRENCY" --repeat "1" --sqlite "./sites/default/files/tests.sqlite" --dburl $SIMPLETEST_DB --url $SIMPLETEST_BASE_URL --verbose --non-html --all --ci-parallel-node-index $CI_PARALLEL_NODE_INDEX --ci-parallel-node-total $CI_PARALLEL_NODE_TOTAL .run-repeat-class-test: &run-repeat-class-test script: @@ -234,7 +234,7 @@ variables: script: - sudo -u www-data -E -H composer run-script drupal-phpunit-upgrade-check # Run a small subset of tests to prove non W3C testing still works. - - sudo -u www-data -E -H php ./core/scripts/run-tests.sh --color --keep-results --types "$TESTSUITE" --concurrency "$CONCURRENCY" --repeat "1" --sqlite "./sites/default/files/tests.sqlite" --dburl $SIMPLETEST_DB --url $SIMPLETEST_BASE_URL --verbose --non-html javascript + - sudo -u www-data -E -H php ./core/scripts/run-tests.sh --debug-discovery --color --keep-results --types "$TESTSUITE" --concurrency "$CONCURRENCY" --repeat "1" --sqlite "./sites/default/files/tests.sqlite" --dburl $SIMPLETEST_DB --url $SIMPLETEST_BASE_URL --verbose --non-html javascript after_script: - sed -i "s#$CI_PROJECT_DIR/##" ./sites/default/files/simpletest/phpunit-*.xml || true diff --git a/composer.json b/composer.json index afccd14e1d2..277aaa5ca86 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "colinodell/psr-testlogger": "^1.2", "composer/composer": "^2.8.1", "drupal/coder": "^8.3.10", - "justinrainbow/json-schema": "^5.2", + "justinrainbow/json-schema": "^5.2 || ^6.3", "lullabot/mink-selenium2-driver": "^1.7.3", "lullabot/php-webdriver": "^2.0.5", "mglaman/phpstan-drupal": "^1.2.11 || ^2.0.4", @@ -35,12 +35,12 @@ "phpstan/phpstan": "^1.12.4 || ^2.1.14", "phpstan/phpstan-phpunit": "^1.3.16 || ^2.0.6", "phpunit/phpunit": "^10.5.19 || ^11.5.3", - "symfony/browser-kit": "^7.2", - "symfony/css-selector": "^7.2", - "symfony/dom-crawler": "^7.2", - "symfony/error-handler": "^7.2", - "symfony/lock": "^7.2", - "symfony/var-dumper": "^7.2" + "symfony/browser-kit": "^7.3@beta", + "symfony/css-selector": "^7.3@beta", + "symfony/dom-crawler": "^7.3@beta", + "symfony/error-handler": "^7.3@beta", + "symfony/lock": "^7.3@beta", + "symfony/var-dumper": "^7.3@beta" }, "replace": { "symfony/polyfill-php72": "*", diff --git a/composer.lock b/composer.lock index f9484b94396..45bf0492fdb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dcd79681afda0e50ae02b69ee396b5d3", + "content-hash": "53d1316a007b7c509b5bda590321ed8f", "packages": [ { "name": "asm89/stack-cors", @@ -497,7 +497,7 @@ "dist": { "type": "path", "url": "core", - "reference": "3fe3a026102b1fb6c77111c00ec61a34a42f3645" + "reference": "3806cfdbb344a5c7ed6e8b78534454547ccd882a" }, "require": { "asm89/stack-cors": "^2.3", @@ -539,7 +539,8 @@ "symfony/http-kernel": "^7.3@beta", "symfony/mailer": "^7.3@beta", "symfony/mime": "^7.3@beta", - "symfony/polyfill-iconv": "^1.26", + "symfony/polyfill-iconv": "^1.32", + "symfony/polyfill-php84": "^1.32", "symfony/process": "^7.3@beta", "symfony/psr-http-message-bridge": "^7.3@beta", "symfony/routing": "^7.3@beta", @@ -2321,16 +2322,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.2.5", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b" + "reference": "47a96276149f049ba944cbd470f4d17bf42914e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/47a96276149f049ba944cbd470f4d17bf42914e3", + "reference": "47a96276149f049ba944cbd470f4d17bf42914e3", "shasum": "" }, "require": { @@ -2343,9 +2344,11 @@ "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0" + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -2376,7 +2379,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.5" + "source": "https://github.com/symfony/error-handler/tree/v7.3.0-BETA1" }, "funding": [ { @@ -2392,7 +2395,7 @@ "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-03-17T19:44:19+00:00" }, { "name": "symfony/event-dispatcher", @@ -3520,6 +3523,82 @@ "time": "2024-12-23T08:48:59+00:00" }, { + "name": "symfony/polyfill-php84", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "000df7860439609837bbe28670b0be15783b7fbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-20T12:04:08+00:00" + }, + { "name": "symfony/process", "version": "v7.3.0-BETA1", "source": { @@ -3927,16 +4006,16 @@ }, { "name": "symfony/string", - "version": "v7.2.6", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", - "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -3994,7 +4073,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.6" + "source": "https://github.com/symfony/string/tree/v7.3.0-BETA1" }, "funding": [ { @@ -4010,7 +4089,7 @@ "type": "tidelift" } ], - "time": "2025-04-20T20:18:16+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/translation-contracts", @@ -4189,20 +4268,21 @@ }, { "name": "symfony/var-dumper", - "version": "v7.2.6", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb" + "reference": "5be5bdd07600c270083d821a4b20697a47526311" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9c46038cd4ed68952166cf7001b54eb539184ccb", - "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/5be5bdd07600c270083d821a4b20697a47526311", + "reference": "5be5bdd07600c270083d821a4b20697a47526311", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -4252,7 +4332,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.0-BETA1" }, "funding": [ { @@ -4268,24 +4348,25 @@ "type": "tidelift" } ], - "time": "2025-04-09T08:14:01+00:00" + "time": "2025-04-09T08:14:14+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.2.6", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "422b8de94c738830a1e071f59ad14d67417d7007" + "reference": "6d25a2377310c85f0400797e4f07c303df00bd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/422b8de94c738830a1e071f59ad14d67417d7007", - "reference": "422b8de94c738830a1e071f59ad14d67417d7007", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6d25a2377310c85f0400797e4f07c303df00bd74", + "reference": "6d25a2377310c85f0400797e4f07c303df00bd74", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "symfony/property-access": "^6.4|^7.0", @@ -4328,7 +4409,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.2.6" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.0-BETA1" }, "funding": [ { @@ -4344,7 +4425,7 @@ "type": "tidelift" } ], - "time": "2025-05-02T08:36:00+00:00" + "time": "2025-05-02T08:36:13+00:00" }, { "name": "symfony/yaml", @@ -9369,16 +9450,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.2.4", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "8ce0ee23857d87d5be493abba2d52d1f9e49da61" + "reference": "5384291845e74fd7d54f3d925c4a86ce12336593" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/8ce0ee23857d87d5be493abba2d52d1f9e49da61", - "reference": "8ce0ee23857d87d5be493abba2d52d1f9e49da61", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5384291845e74fd7d54f3d925c4a86ce12336593", + "reference": "5384291845e74fd7d54f3d925c4a86ce12336593", "shasum": "" }, "require": { @@ -9417,7 +9498,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.2.4" + "source": "https://github.com/symfony/browser-kit/tree/v7.3.0-BETA1" }, "funding": [ { @@ -9433,11 +9514,11 @@ "type": "tidelift" } ], - "time": "2025-02-14T14:27:24+00:00" + "time": "2025-03-05T10:15:41+00:00" }, { "name": "symfony/css-selector", - "version": "v7.2.0", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -9482,7 +9563,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0-BETA1" }, "funding": [ { @@ -9502,16 +9583,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.2.4", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7" + "reference": "0fabbc3d6a9c473b716a93fc8e7a537adb396166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7", - "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0fabbc3d6a9c473b716a93fc8e7a537adb396166", + "reference": "0fabbc3d6a9c473b716a93fc8e7a537adb396166", "shasum": "" }, "require": { @@ -9549,7 +9630,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.2.4" + "source": "https://github.com/symfony/dom-crawler/tree/v7.3.0-BETA1" }, "funding": [ { @@ -9565,20 +9646,20 @@ "type": "tidelift" } ], - "time": "2025-02-17T15:53:07+00:00" + "time": "2025-03-05T10:15:41+00:00" }, { "name": "symfony/lock", - "version": "v7.2.6", + "version": "v7.3.0-BETA1", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "69599a1d602a6c66fc69cdf733839480d01a06be" + "reference": "5bef45fb874b0454a616ac8091447a7982a438cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/69599a1d602a6c66fc69cdf733839480d01a06be", - "reference": "69599a1d602a6c66fc69cdf733839480d01a06be", + "url": "https://api.github.com/repos/symfony/lock/zipball/5bef45fb874b0454a616ac8091447a7982a438cf", + "reference": "5bef45fb874b0454a616ac8091447a7982a438cf", "shasum": "" }, "require": { @@ -9627,7 +9708,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v7.2.6" + "source": "https://github.com/symfony/lock/tree/v7.3.0-BETA1" }, "funding": [ { @@ -9643,7 +9724,7 @@ "type": "tidelift" } ], - "time": "2025-04-17T22:02:25+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "tbachert/spi", @@ -9858,12 +9939,18 @@ "drupal/core": 20, "drupal/core-project-message": 20, "drupal/core-recipe-unpack": 20, - "drupal/core-vendor-hardening": 20 + "drupal/core-vendor-hardening": 20, + "symfony/browser-kit": 10, + "symfony/css-selector": 10, + "symfony/dom-crawler": 10, + "symfony/error-handler": 10, + "symfony/lock": 10, + "symfony/var-dumper": 10 }, "prefer-stable": true, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "platform-overrides": { "php": "8.3.0" }, diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json index bc11c39d9b8..48e38f50d34 100644 --- a/composer/Metapackage/CoreRecommended/composer.json +++ b/composer/Metapackage/CoreRecommended/composer.json @@ -36,7 +36,7 @@ "symfony/console": "~v7.3.0-BETA1", "symfony/dependency-injection": "~v7.3.0-BETA1", "symfony/deprecation-contracts": "~v3.5.1", - "symfony/error-handler": "~v7.2.5", + "symfony/error-handler": "~v7.3.0-BETA1", "symfony/event-dispatcher": "~v7.3.0-BETA1", "symfony/event-dispatcher-contracts": "~v3.5.1", "symfony/filesystem": "~v7.3.0-BETA1", @@ -51,16 +51,17 @@ "symfony/polyfill-intl-idn": "~v1.32.0", "symfony/polyfill-intl-normalizer": "~v1.32.0", "symfony/polyfill-mbstring": "~v1.32.0", + "symfony/polyfill-php84": "~v1.32.0", "symfony/process": "~v7.3.0-BETA1", "symfony/psr-http-message-bridge": "~v7.3.0-BETA1", "symfony/routing": "~v7.3.0-BETA1", "symfony/serializer": "~v7.3.0-BETA1", "symfony/service-contracts": "~v3.5.1", - "symfony/string": "~v7.2.6", + "symfony/string": "~v7.3.0-BETA1", "symfony/translation-contracts": "~v3.5.1", "symfony/validator": "~v7.3.0-BETA1", - "symfony/var-dumper": "~v7.2.6", - "symfony/var-exporter": "~v7.2.6", + "symfony/var-dumper": "~v7.3.0-BETA1", + "symfony/var-exporter": "~v7.3.0-BETA1", "symfony/yaml": "~v7.3.0-BETA1", "twig/twig": "~v3.21.1" } diff --git a/composer/Metapackage/DevDependencies/composer.json b/composer/Metapackage/DevDependencies/composer.json index 674d8e9f7fb..3d865a42278 100644 --- a/composer/Metapackage/DevDependencies/composer.json +++ b/composer/Metapackage/DevDependencies/composer.json @@ -12,7 +12,7 @@ "colinodell/psr-testlogger": "^1.2", "composer/composer": "^2.8.1", "drupal/coder": "^8.3.10", - "justinrainbow/json-schema": "^5.2", + "justinrainbow/json-schema": "^5.2 || ^6.3", "lullabot/mink-selenium2-driver": "^1.7.3", "lullabot/php-webdriver": "^2.0.5", "mglaman/phpstan-drupal": "^1.2.11 || ^2.0.4", @@ -26,11 +26,11 @@ "phpstan/phpstan": "^1.12.4 || ^2.1.14", "phpstan/phpstan-phpunit": "^1.3.16 || ^2.0.6", "phpunit/phpunit": "^10.5.19 || ^11.5.3", - "symfony/browser-kit": "^7.2", - "symfony/css-selector": "^7.2", - "symfony/dom-crawler": "^7.2", - "symfony/error-handler": "^7.2", - "symfony/lock": "^7.2", - "symfony/var-dumper": "^7.2" + "symfony/browser-kit": "^7.3@beta", + "symfony/css-selector": "^7.3@beta", + "symfony/dom-crawler": "^7.3@beta", + "symfony/error-handler": "^7.3@beta", + "symfony/lock": "^7.3@beta", + "symfony/var-dumper": "^7.3@beta" } } diff --git a/composer/Metapackage/PinnedDevDependencies/composer.json b/composer/Metapackage/PinnedDevDependencies/composer.json index 5518fa7cbaf..992139dea51 100644 --- a/composer/Metapackage/PinnedDevDependencies/composer.json +++ b/composer/Metapackage/PinnedDevDependencies/composer.json @@ -83,10 +83,10 @@ "sirbrillig/phpcs-variable-analysis": "v2.12.0", "slevomat/coding-standard": "8.18.0", "squizlabs/php_codesniffer": "3.12.2", - "symfony/browser-kit": "v7.2.4", - "symfony/css-selector": "v7.2.0", - "symfony/dom-crawler": "v7.2.4", - "symfony/lock": "v7.2.6", + "symfony/browser-kit": "v7.3.0-BETA1", + "symfony/css-selector": "v7.3.0-BETA1", + "symfony/dom-crawler": "v7.3.0-BETA1", + "symfony/lock": "v7.3.0-BETA1", "tbachert/spi": "v1.0.3", "theseer/tokenizer": "1.2.3", "webflo/drupal-finder": "1.3.1", diff --git a/core/.deprecation-ignore.txt b/core/.deprecation-ignore.txt index 3eb0b15f07a..12772fbb55b 100644 --- a/core/.deprecation-ignore.txt +++ b/core/.deprecation-ignore.txt @@ -2,37 +2,33 @@ # deprecated code. # See https://www.drupal.org/node/3285162 for more details. -%The "Symfony\\Component\\Validator\\Context\\ExecutionContextInterface::.*\(\)" method is considered internal Used by the validator engine\. (Should not be called by user\W+code\. )?It may change without further notice\. You should not extend it from "[^"]+"\.% +# @todo Remove when we no longer support PHPUnit 10. +%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered internal.* You should not extend it from "Drupal\\[^"]+"% -# Skip some dependencies' DebugClassLoader forward compatibility warnings. -%Method "Behat\\[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Doctrine\\Common\\Annotations\\Reader::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\Extension\\ExtensionInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\Loader\\FilesystemLoader::findTemplate\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\Loader\\LoaderInterface::exists\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\Node\\Node::compile\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\NodeVisitor\\AbstractNodeVisitor::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "Twig\\TokenParser\\TokenParserInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "WebDriver\\Service\\CurlServiceInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% - -# Indirect deprecations. These are not in Drupal's remit to fix, but it is -# worth keeping track of dependencies' issues. -%Method "[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "org\\bovigo\\vfs\\[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% -%Method "[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "OpenTelemetry\\[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message% - -# The following deprecation is listed for Twig 2 compatibility when unit -# testing using \Symfony\Component\ErrorHandler\DebugClassLoader. -%The "Twig\\Template" class is considered internal\. It may change without further notice\. You should not use it from "Drupal\\Tests\\Core\\Template\\StubTwigTemplate"\.% +# Internal code that we cannot avoid extending. +%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered final.* You should not extend it from "Drupal\\[^"]+"% %The "Twig\\Environment::getTemplateClass\(\)" method is considered internal\. It may change without further notice\. You should not extend it from "Drupal\\Core\\Template\\TwigEnvironment"\.% -# PHPUnit 10. -%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered internal.*You should not extend it from "Drupal\\[^"]+"% +# Skip some dependencies' DebugClassLoader forward compatibility warnings, in +# order to let contrib modules make their necessary fixes first. +%Method "Behat\\Mink\\Driver\\CoreDriver::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\FunctionalJavascriptTests\\DrupalSelenium2Driver" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Behat\\Mink\\WebAssert::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\FunctionalJavascriptTests\\WebDriverWebAssert" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Behat\\Mink\\WebAssert::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Tests\\WebAssert" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Doctrine\\Common\\Annotations\\Reader::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Component\\Annotation\\Doctrine\\SimpleAnnotationReader" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\Extension\\ExtensionInterface::[^"]+" might add "array" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigExtension" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\Loader\\FilesystemLoader::findTemplate\(\)" might add "\?string" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\Loader\\FilesystemLoader" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\Loader\\LoaderInterface::exists\(\)" might add "bool" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\Loader\\StringLoader" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\Node\\Node::compile\(\)" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\TwigNodeCheckDeprecations" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\Node\\Node::compile\(\)" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\TwigNodeTrans" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\RemoveCheckToStringNodeVisitor" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigNodeVisitor" now to avoid errors or add an explicit @return annotation to suppress this message% +%Method "Twig\\TokenParser\\TokenParserInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigTransTokenParser" now to avoid errors or add an explicit @return annotation to suppress this message% -# PHPUnit 11. -%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered final\. It may change without further notice as of its next major version\. You should not extend it from "Drupal\\[^"]+"% +# Indirect deprecations. These are not in Drupal's remit to fix, but it is +# worth keeping track of dependencies' issues. +%Method "Iterator::[^"]+" might add "void" as a native return type declaration in the future. Do the same in implementation "org\\bovigo\\vfs\\vfsStreamContainerIterator" now to avoid errors or add an explicit @return annotation to suppress this message% -# Symfony 7.2 +# Symfony 7.2. %Since symfony/http-foundation 7.2: NativeSessionStorage's "sid_length" option is deprecated and will be ignored in Symfony 8.0.% %Since symfony/http-foundation 7.2: NativeSessionStorage's "sid_bits_per_character" option is deprecated and will be ignored in Symfony 8.0.% @@ -44,5 +40,5 @@ %The "Drupal\\Core\\Entity\\Query\\QueryBase::hasAllTags\(\)" method will require a new "string \.\.\. \$tags" argument in the next major version of its interface% %The "Drupal\\Core\\Entity\\Query\\QueryBase::hasAnyTag\(\)" method will require a new "string \.\.\. \$tags" argument in the next major version of its interface% -# Symfony 7.3 +# Symfony 7.3. %Since symfony/validator 7.3: Passing an array of options to configure the "[^"]+" constraint is deprecated, use named arguments instead.% diff --git a/core/.eslintrc.json b/core/.eslintrc.json index 6a6916b1a2c..7b1ed46abec 100644 --- a/core/.eslintrc.json +++ b/core/.eslintrc.json @@ -21,6 +21,7 @@ "_": true, "Cookies": true, "Backbone": true, + "htmx": true, "loadjs": true, "Shepherd": true, "Sortable": true, diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php index 7c1faafbcff..b7f87b4ee01 100644 --- a/core/.phpstan-baseline.php +++ b/core/.phpstan-baseline.php @@ -1052,12 +1052,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/lib/Drupal/Component/Plugin/LazyPluginCollection.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\Component\\\\Plugin\\\\LazyPluginCollection\\:\\:getIterator\\(\\) return type with generic class ArrayIterator does not specify its types\\: TKey, TValue$#', - 'identifier' => 'missingType.generics', - 'count' => 1, - 'path' => __DIR__ . '/lib/Drupal/Component/Plugin/LazyPluginCollection.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Component\\\\Plugin\\\\LazyPluginCollection\\:\\:initializePlugin\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -9812,12 +9806,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/lib/Drupal/Core/Render/Renderer.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\Core\\\\Render\\\\Renderer\\:\\:doRender\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/lib/Drupal/Core/Render/Renderer.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Core\\\\Render\\\\Renderer\\:\\:renderPlain\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -15842,12 +15830,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php', ]; $ignoreErrors[] = [ - 'message' => '#^Variable \\$unrestricted_tab_count might not be defined\\.$#', - 'identifier' => 'variable.undefined', - 'count' => 1, - 'path' => __DIR__ . '/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\datetime\\\\DateTimeComputed\\:\\:setValue\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -34390,12 +34372,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/modules/system/tests/modules/services_defaults_test/src/TestService.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\session_test\\\\Controller\\\\SessionTestController\\:\\:triggerWriteException\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\session_test\\\\EventSubscriber\\\\SessionTestSubscriber\\:\\:onKernelRequestSessionTest\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -48530,6 +48506,30 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php', ]; $ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\Tests\\\\workspaces\\\\Functional\\\\WorkspaceFormValidationTest\\:\\:isLabelInContentOverview\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\Tests\\\\workspaces\\\\Functional\\\\WorkspaceFormValidationTest\\:\\:setupWorkspaceSwitcherBlock\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\Tests\\\\workspaces\\\\Functional\\\\WorkspaceFormValidationTest\\:\\:switchToLive\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\Tests\\\\workspaces\\\\Functional\\\\WorkspaceFormValidationTest\\:\\:switchToWorkspace\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php', +]; +$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Tests\\\\workspaces\\\\Functional\\\\WorkspaceMenuLinkContentIntegrationTest\\:\\:isLabelInContentOverview\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -51074,6 +51074,174 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php', ]; $ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBaseField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBaseFieldIndex\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBundleField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addEntityIndex\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addLongNameBaseField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addRevisionableBaseField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:applyEntityUpdates\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:deleteEntityType\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:doEntityUpdate\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:doFieldUpdate\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:enableNewEntityType\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:makeBaseFieldEntityKey\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:modifyBaseField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:modifyBundleField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBaseField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBaseFieldIndex\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBundleField\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeEntityIndex\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameBaseTable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameDataTable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameRevisionBaseTable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameRevisionDataTable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:resetEntityType\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToNotRevisionable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToNotTranslatable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToRevisionable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToRevisionableAndTranslatable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToTranslatable\\(\\) has no return type specified\\.$#', + 'identifier' => 'missingType.return', + 'count' => 1, + 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php', +]; +$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateProviderTest\\:\\:addBaseField\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, diff --git a/core/.phpunit-next.xml b/core/.phpunit-next.xml new file mode 100644 index 00000000000..944ec9dfadd --- /dev/null +++ b/core/.phpunit-next.xml @@ -0,0 +1,164 @@ +<?xml version="1.0" encoding="UTF-8"?> + + +<!-- This is a near-copy of phpunit.xml.dist, used to test with upcoming + PHPUnit versions. + + Current differences: + * for PHPUnit 11: removed duplicate directories from <testsuites>, + required in PHPUnit 10, but that cause duplication warnings in + PHPUnit 11. +--> + + +<!-- For how to customize PHPUnit configuration, see core/tests/README.md. --> +<!-- TODO set checkForUnintentionallyCoveredCode="true" once https://www.drupal.org/node/2626832 is resolved. --> +<!-- PHPUnit expects functional tests to be run with either a privileged user + or your current system user. See core/tests/README.md and + https://www.drupal.org/node/2116263 for details. +--> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + bootstrap="tests/bootstrap.php" + colors="true" + beStrictAboutTestsThatDoNotTestAnything="true" + beStrictAboutOutputDuringTests="true" + beStrictAboutChangesToGlobalState="true" + failOnRisky="true" + failOnWarning="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + cacheResult="false" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" + cacheDirectory=".phpunit.cache"> + <php> + <!-- Set error reporting to E_ALL. --> + <ini name="error_reporting" value="32767"/> + <!-- Do not limit the amount of memory tests take to run. --> + <ini name="memory_limit" value="-1"/> + <!-- Example SIMPLETEST_BASE_URL value: http://localhost --> + <env name="SIMPLETEST_BASE_URL" value=""/> + <!-- Example SIMPLETEST_DB value: mysql://username:password@localhost/database_name#table_prefix --> + <env name="SIMPLETEST_DB" value=""/> + <!-- By default, browser tests will output links that use the base URL set + in SIMPLETEST_BASE_URL. However, if your SIMPLETEST_BASE_URL is an internal + path (such as may be the case in a virtual or Docker-based environment), + you can set the base URL used in the browser test output links to something + reachable from your host machine here. This will allow you to follow them + directly and view the output. --> + <env name="BROWSERTEST_OUTPUT_BASE_URL" value=""/> + <!-- The environment variable SYMFONY_DEPRECATIONS_HELPER is used to configure + the behavior of the deprecation tests. + Drupal core's testing framework is setting this variable to its defaults. + Projects with their own requirements need to manage this variable + explicitly. + --> + <!-- To disable deprecation testing completely uncomment the next line. --> + <!-- <env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled"/> --> + <!-- Deprecation errors can be selectively ignored by specifying a file of + regular expression patterns for exclusion. + Uncomment the line below to specify a custom deprecations ignore file. + NOTE: it may be required to specify the full path to the file to run tests + correctly. + --> + <!-- <env name="SYMFONY_DEPRECATIONS_HELPER" value="ignoreFile=.deprecation-ignore.txt"/> --> + <!-- Example for changing the driver class for mink tests MINK_DRIVER_CLASS value: 'Drupal\FunctionalJavascriptTests\DrupalSelenium2Driver' --> + <env name="MINK_DRIVER_CLASS" value=""/> + <!-- Example for changing the driver args to mink tests MINK_DRIVER_ARGS value: '["http://127.0.0.1:8510"]' --> + <env name="MINK_DRIVER_ARGS" value=""/> + <!-- Example for changing the driver args to webdriver tests MINK_DRIVER_ARGS_WEBDRIVER value: '["chrome", { "goog:chromeOptions": { "w3c": false } }, "http://localhost:4444/wd/hub"]' For using the Firefox browser, replace "chrome" with "firefox" --> + <env name="MINK_DRIVER_ARGS_WEBDRIVER" value=""/> + </php> + <extensions> + <!-- Functional tests HTML output logging. --> + <bootstrap class="Drupal\TestTools\Extension\HtmlLogging\HtmlOutputLogger"> + <!-- The directory where the browser output will be stored. If a relative + path is specified, it will be relative to the current working directory + of the process running the PHPUnit CLI. In CI environments, this can be + overridden by the value set for the "BROWSERTEST_OUTPUT_DIRECTORY" + environment variable. + --> + <parameter name="outputDirectory" value="sites/simpletest/browser_output"/> + <!-- By default browser tests print the individual links in the test run + report. To avoid overcrowding the output in CI environments, you can + set the "verbose" parameter or the "BROWSERTEST_OUTPUT_VERBOSE" + environment variable to "false". In GitLabCI, the output is saved + anyway as an artifact that can be browsed or downloaded from Gitlab. + --> + <parameter name="verbose" value="true"/> + </bootstrap> + <!-- Debug dump() printer. --> + <bootstrap class="Drupal\TestTools\Extension\Dump\DebugDump"> + <parameter name="colors" value="true"/> + <parameter name="printCaller" value="true"/> + </bootstrap> + </extensions> + <testsuites> + <testsuite name="unit-component"> + <directory>tests/Drupal/Tests/Component</directory> + </testsuite> + <testsuite name="unit"> + <directory>tests/Drupal/Tests</directory> + <exclude>tests/Drupal/Tests/Component</exclude> + <directory>modules/**/tests/src/Unit</directory> + <directory>profiles/**/tests/src/Unit</directory> + <directory>themes/**/tests/src/Unit</directory> + <directory>../modules/**/tests/src/Unit</directory> + <directory>../profiles/**/tests/src/Unit</directory> + <directory>../themes/**/tests/src/Unit</directory> + </testsuite> + <testsuite name="kernel"> + <directory>tests/Drupal/KernelTests</directory> + <directory>modules/**/tests/src/Kernel</directory> + <directory>recipes/*/tests/src/Kernel</directory> + <directory>profiles/**/tests/src/Kernel</directory> + <directory>themes/**/tests/src/Kernel</directory> + <directory>../modules/**/tests/src/Kernel</directory> + <directory>../profiles/**/tests/src/Kernel</directory> + <directory>../themes/**/tests/src/Kernel</directory> + </testsuite> + <testsuite name="functional"> + <directory>tests/Drupal/FunctionalTests</directory> + <directory>modules/**/tests/src/Functional</directory> + <directory>profiles/**/tests/src/Functional</directory> + <directory>recipes/*/tests/src/Functional</directory> + <directory>themes/**/tests/src/Functional</directory> + <directory>../modules/**/tests/src/Functional</directory> + <directory>../profiles/**/tests/src/Functional</directory> + <directory>../themes/**/tests/src/Functional</directory> + </testsuite> + <testsuite name="functional-javascript"> + <directory>tests/Drupal/FunctionalJavascriptTests</directory> + <directory>modules/**/tests/src/FunctionalJavascript</directory> + <directory>recipes/*/tests/src/FunctionalJavascript</directory> + <directory>profiles/**/tests/src/FunctionalJavascript</directory> + <directory>themes/**/tests/src/FunctionalJavascript</directory> + <directory>../modules/**/tests/src/FunctionalJavascript</directory> + <directory>../profiles/**/tests/src/FunctionalJavascript</directory> + <directory>../themes/**/tests/src/FunctionalJavascript</directory> + </testsuite> + <testsuite name="build"> + <directory>tests/Drupal/BuildTests</directory> + <directory>modules/**/tests/src/Build</directory> + </testsuite> + </testsuites> + <!-- Settings for coverage reports. --> + <source ignoreSuppressionOfDeprecations="true"> + <include> + <directory>./includes</directory> + <directory>./lib</directory> + <directory>./modules</directory> + <directory>../modules</directory> + <directory>../sites</directory> + </include> + <exclude> + <directory>./lib/Drupal/Component</directory> + <directory>./modules/*/src/Tests</directory> + <directory>./modules/*/tests</directory> + <directory>../modules/*/src/Tests</directory> + <directory>../modules/*/tests</directory> + <directory>../modules/*/*/src/Tests</directory> + <directory>../modules/*/*/tests</directory> + </exclude> + </source> +</phpunit> diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index e44df814938..0ef1dde096f 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -22,6 +22,10 @@ Product managers - Lauri Timmanee 'lauriii' https://www.drupal.org/u/lauriii - Roy Scholten 'yoroy' https://www.drupal.org/u/yoroy +User Experience (UX) managers +- Cristina Chumillas 'ckrina' https://www.drupal.org/u/ckrina +- (provisional) Emma Horrell 'emma-horrell' https://www.drupal.org/u/emma-horrell + Framework managers Backend @@ -210,7 +214,7 @@ Entity API - Sascha Grossenbacher 'Berdir' https://www.drupal.org/u/berdir Extension API -- ? +- Nic Laflin 'nicxvan' https://www.drupal.org/u/nicxvan Field API - Andrei Mateescu 'amateescu' https://www.drupal.org/u/amateescu @@ -228,6 +232,7 @@ Filter Form API - Alex Bronstein 'effulgentsia' https://www.drupal.org/u/effulgentsia - Tim Plunkett 'tim.plunkett' https://www.drupal.org/u/tim.plunkett +- Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan History - Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost @@ -354,6 +359,7 @@ Recipes Render API - Alex Bronstein 'effulgentsia' https://www.drupal.org/u/effulgentsia - Moshe Weitzman 'moshe weitzman' https://www.drupal.org/u/moshe-weitzman +- Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan Request Processing - ? diff --git a/core/assets/schemas/v1/metadata-full.schema.json b/core/assets/schemas/v1/metadata-full.schema.json index 8cda1cb413b..ac275eb1bdf 100644 --- a/core/assets/schemas/v1/metadata-full.schema.json +++ b/core/assets/schemas/v1/metadata-full.schema.json @@ -4,6 +4,7 @@ "$defs": { "slotDefinition": { "type": "object", + "additionalProperties": false, "patternProperties": { "^[a-zA-Z0-9_-]+$": { "type": "object", diff --git a/core/assets/schemas/v1/metadata.schema.json b/core/assets/schemas/v1/metadata.schema.json index b8e6b6119b3..5e5c753360f 100644 --- a/core/assets/schemas/v1/metadata.schema.json +++ b/core/assets/schemas/v1/metadata.schema.json @@ -4,6 +4,7 @@ "$defs": { "slotDefinition": { "type": "object", + "additionalProperties": false, "patternProperties": { "^[a-zA-Z0-9_-]+$": { "type": "object", diff --git a/core/composer.json b/core/composer.json index 59bca714e5b..af9d9ca3162 100644 --- a/core/composer.json +++ b/core/composer.json @@ -32,7 +32,8 @@ "symfony/serializer": "^7.3@beta", "symfony/validator": "^7.3@beta", "symfony/process": "^7.3@beta", - "symfony/polyfill-iconv": "^1.26", + "symfony/polyfill-iconv": "^1.32", + "symfony/polyfill-php84": "^1.32", "symfony/yaml": "^7.3@beta", "revolt/event-loop": "^1.0", "twig/twig": "^3.21.0", diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index b7798d0373d..3e88997a1cf 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -940,6 +940,18 @@ field.value.timestamp: type: timestamp label: 'Value' +field.value.language: + type: mapping + label: 'Language value' + mapping: + value: + # Not this doesn't make use of type: langcode because that doesn't allow null values. + type: string + label: 'Language value' + constraints: + Choice: + callback: 'Drupal\Core\TypedData\Plugin\DataType\LanguageReference::getAllValidLangcodes' + # Text with a text format. text_format: type: mapping diff --git a/core/core.libraries.yml b/core/core.libraries.yml index f62da33d128..75cc6c6684f 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -614,6 +614,24 @@ drupal.form: - core/drupal.debounce - core/once +drupal.htmx: + version: VERSION + js: + misc/htmx/htmx-assets.js: {} + misc/htmx/htmx-behaviors.js: {} + dependencies: + - core/htmx + - core/drupal + - core/drupalSettings + - core/loadjs + drupalSettings: + # These placeholder values will be set by system_js_settings_alter(). + ajaxPageState: + libraries: null + theme: null + theme_token: null + ajaxTrustedUrl: {} + drupal.machine-name: version: VERSION js: diff --git a/core/core.services.yml b/core/core.services.yml index a337173741f..789eee02214 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -709,7 +709,7 @@ services: Drupal\Core\Extension\ThemeHandlerInterface: '@theme_handler' theme_installer: class: Drupal\Core\Extension\ThemeInstaller - arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module', '@theme.registry', '@extension.list.theme'] + arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module', '@theme.registry', '@extension.list.theme', '@plugin.manager.sdc'] Drupal\Core\Extension\ThemeInstallerInterface: '@theme_installer' entity.memory_cache: class: Drupal\Core\Cache\MemoryCache\LruMemoryCache @@ -831,7 +831,7 @@ services: Drupal\Core\Menu\DefaultMenuLinkTreeManipulators: '@menu.default_tree_manipulators' menu.active_trail: class: Drupal\Core\Menu\MenuActiveTrail - arguments: ['@plugin.manager.menu.link', '@current_route_match', '@cache.menu', '@lock'] + arguments: ['@plugin.manager.menu.link', '@current_route_match', '@cache.menu', '@lock', '@path.matcher'] tags: - { name: needs_destruction } lazy: true diff --git a/core/includes/common.inc b/core/includes/common.inc index 47f360caffe..9c60a811ce3 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -10,6 +10,7 @@ use Drupal\Component\Utility\SortArray; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheTagsPurgeInterface; use Drupal\Core\DrupalKernel; /** @@ -418,6 +419,13 @@ function drupal_flush_all_caches($kernel = NULL): void { $module_handler = \Drupal::moduleHandler(); // Flush all persistent caches. $module_handler->invokeAll('cache_flush'); + // Purge cache tags immediately before flushing cache bins. If a cache tag is + // invalidated between the tags being purged and cache bins are flushed, then + // it will be included in the checksum of any new cache items, but still valid + // because the tag was written before the creation of cache item. + if (($invalidator = \Drupal::service('cache_tags.invalidator')) && ($invalidator instanceof CacheTagsPurgeInterface)) { + $invalidator->purge(); + } foreach (Cache::getBins() as $cache_backend) { $cache_backend->deleteAll(); } diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index 7d1b4da8a98..cde18fc831e 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -17,6 +17,7 @@ use Drupal\Core\DrupalKernel; use Drupal\Core\Database\Database; use Drupal\Core\Database\DatabaseExceptionWrapper; use Drupal\Core\Extension\Exception\UnknownExtensionException; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Form\FormState; use Drupal\Core\Installer\Exception\AlreadyInstalledException; @@ -2018,7 +2019,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['translations directory exists'] = [ 'title' => t('Translations directory'), 'value' => t('The translations directory does not exist.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The installer requires that you create a translations directory as part of the installation process. Create the directory %translations_directory . More details about installing Drupal are available in <a href=":install_txt">INSTALL.txt</a>.', ['%translations_directory' => $translations_directory, ':install_txt' => base_path() . 'core/INSTALL.txt']), ]; } @@ -2032,7 +2033,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['translations directory readable'] = [ 'title' => t('Translations directory'), 'value' => t('The translations directory is not readable.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The installer requires read permissions to %translations_directory at all times. The <a href=":handbook_url">webhosting issues</a> documentation section offers help on this and other topics.', ['%translations_directory' => $translations_directory, ':handbook_url' => 'https://www.drupal.org/server-permissions']), ]; } @@ -2041,7 +2042,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['translations directory writable'] = [ 'title' => t('Translations directory'), 'value' => t('The translations directory is not writable.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The installer requires write permissions to %translations_directory during the installation process. The <a href=":handbook_url">webhosting issues</a> documentation section offers help on this and other topics.', ['%translations_directory' => $translations_directory, ':handbook_url' => 'https://www.drupal.org/server-permissions']), ]; } @@ -2058,7 +2059,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['online'] = [ 'title' => t('Internet'), 'value' => t('The translation server is offline.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The installer requires to contact the translation server to download a translation file. Check your internet connection and verify that your website can reach the translation server at <a href=":server_url">@server_url</a>.', [':server_url' => $server_url, '@server_url' => $server_url]), ]; } @@ -2073,7 +2074,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['translation available'] = [ 'title' => t('Translation'), 'value' => t('The %language translation is not available.', ['%language' => $language]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The %language translation file is not available at the translation server. <a href=":url">Choose a different language</a> or select English and translate your website later.', ['%language' => $language, ':url' => $_SERVER['SCRIPT_NAME']]), ]; } @@ -2092,7 +2093,7 @@ function install_check_translations($langcode, $server_pattern): array { $requirements['translation downloaded'] = [ 'title' => t('Translation'), 'value' => t('The %language translation could not be downloaded.', ['%language' => $language]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The %language translation file could not be downloaded. <a href=":url">Choose a different language</a> or select English and translate your website later.', ['%language' => $language, ':url' => $_SERVER['SCRIPT_NAME']]), ]; } @@ -2152,7 +2153,7 @@ function install_check_requirements($install_state) { $requirements["default $file file exists"] = [ 'title' => $default_file_info['title_default'], 'value' => $default_file_info['description_default'], - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The @drupal installer requires that the %default-file file must not be deleted or modified from the original download.', [ '@drupal' => drupal_install_profile_distribution_name(), '%default-file' => $default_file, @@ -2211,7 +2212,7 @@ function install_check_requirements($install_state) { $requirements["$file file exists"] = [ 'title' => $default_file_info['title'], 'value' => t('The %file does not exist.', ['%file' => $default_file_info['title']]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The @drupal installer requires that you create a %file as part of the installation process. Copy the %default_file file to %file. More details about installing Drupal are available in <a href=":install_txt">INSTALL.txt</a>.', [ '@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, @@ -2230,7 +2231,7 @@ function install_check_requirements($install_state) { $requirements["$file file readable"] = [ 'title' => $default_file_info['title'], 'value' => t('The %file is not readable.', ['%file' => $default_file_info['title']]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('@drupal requires read permissions to %file at all times. The <a href=":handbook_url">webhosting issues</a> documentation section offers help on this and other topics.', [ '@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, @@ -2243,7 +2244,7 @@ function install_check_requirements($install_state) { $requirements["$file file writable"] = [ 'title' => $default_file_info['title'], 'value' => t('The %file is not writable.', ['%file' => $default_file_info['title']]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The @drupal installer requires write permissions to %file during the installation process. The <a href=":handbook_url">webhosting issues</a> documentation section offers help on this and other topics.', [ '@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, @@ -2261,7 +2262,7 @@ function install_check_requirements($install_state) { $requirements["$file file ownership"] = [ 'title' => $default_file_info['title'], 'value' => t('The @file is owned by the web server.', ['@file' => $default_file_info['title']]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The @drupal installer failed to create a %file file with proper file ownership. Log on to your web server, remove the existing %file file, and create a new one by copying the %default_file file to %file. More details about installing Drupal are available in <a href=":install_txt">INSTALL.txt</a>. The <a href=":handbook_url">webhosting issues</a> documentation section offers help on this and other topics.', [ '@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, @@ -2287,7 +2288,7 @@ function install_check_requirements($install_state) { $requirements['database_install_errors'] = [ 'title' => t('Database settings'), 'description' => $error_message, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -2315,18 +2316,18 @@ function install_check_requirements($install_state) { */ function install_display_requirements($install_state, $requirements) { // Check the severity of the requirements reported. - $severity = drupal_requirements_severity($requirements); + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); // If there are errors, always display them. If there are only warnings, skip // them if the user has provided a URL parameter acknowledging the warnings // and indicating a desire to continue anyway. See drupal_requirements_url(). - if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && empty($install_state['parameters']['continue']))) { + if ($severity === RequirementSeverity::Error || ($severity === RequirementSeverity::Warning && empty($install_state['parameters']['continue']))) { if ($install_state['interactive']) { $build['report']['#type'] = 'status_report'; $build['report']['#requirements'] = $requirements; - if ($severity == REQUIREMENT_WARNING) { + if ($severity == RequirementSeverity::Warning) { $build['#title'] = t('Requirements review'); - $build['#suffix'] = t('Check the messages and <a href=":retry">retry</a>, or you may choose to <a href=":cont">continue anyway</a>.', [':retry' => drupal_requirements_url(REQUIREMENT_ERROR), ':cont' => drupal_requirements_url($severity)]); + $build['#suffix'] = t('Check the messages and <a href=":retry">retry</a>, or you may choose to <a href=":cont">continue anyway</a>.', [':retry' => drupal_requirements_url(RequirementSeverity::Error), ':cont' => drupal_requirements_url($severity)]); } else { $build['#title'] = t('Requirements problem'); @@ -2341,7 +2342,7 @@ function install_display_requirements($install_state, $requirements) { // Skip warnings altogether for non-interactive installations; these // proceed in a single request so there is no good opportunity (and no // good method) to warn the user anyway. - if (isset($requirement['severity']) && $requirement['severity'] == REQUIREMENT_ERROR) { + if (isset($requirement['severity']) && $requirement['severity'] === RequirementSeverity::Error) { $render_array = [ '#type' => 'inline_template', '#template' => '{{ title }}:{{ value }}<br /><br />{{ description }}', diff --git a/core/includes/install.inc b/core/includes/install.inc index d1e7e865666..dc181b04381 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -12,26 +12,47 @@ use Drupal\Core\Extension\Dependency; use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Installer\InstallerKernel; use Symfony\Component\HttpFoundation\RedirectResponse; /** * Requirement severity -- Informational message only. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Info instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_INFO = -1; /** * Requirement severity -- Requirement successfully met. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::OK instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_OK = 0; /** * Requirement severity -- Warning condition; proceed but flag warning. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Warning instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_WARNING = 1; /** * Requirement severity -- Error condition; abort installation. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Error instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_ERROR = 2; @@ -190,7 +211,7 @@ function drupal_verify_profile($install_state): array { $requirements['required_modules'] = [ 'title' => t('Required modules'), 'value' => t('Required modules not found.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The following modules are required but were not found. Move them into the appropriate modules subdirectory, such as <em>/modules</em>. Missing modules: @modules', ['@modules' => $modules_list]), ]; } @@ -560,7 +581,7 @@ function drupal_current_script_url($query = []) { * update.php) and returns a URL that can be used to attempt to proceed to the * next step of the script. * - * @param int $severity + * @param int|\Drupal\Core\Extension\Requirement\RequirementSeverity $severity * The severity of the requirements problem, as returned by * drupal_requirements_severity(). * @@ -573,11 +594,18 @@ function drupal_current_script_url($query = []) { * @see drupal_current_script_url() * @see \Drupal\Component\Utility\UrlHelper::filterBadProtocol() */ -function drupal_requirements_url($severity) { +function drupal_requirements_url(/* int|RequirementSeverity */ $severity): string { + if (!$severity instanceof RequirementSeverity) { + @trigger_error('Passing a type other than ' . RequirementSeverity::class . ' to ' . __FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a ' . RequirementSeverity::class . ' enum instead. See https://www.drupal.org/node/3410939', E_USER_DEPRECATED); + $severity = RequirementSeverity::from($severity); + } + if (is_null($severity)) { + $severity = RequirementSeverity::Info; + } $query = []; // If there are no errors, only warnings, append 'continue=1' to the URL so // the user can bypass this screen on the next page load. - if ($severity == REQUIREMENT_WARNING) { + if ($severity === RequirementSeverity::Warning) { $query['continue'] = 1; } return drupal_current_script_url($query); @@ -644,15 +672,16 @@ function drupal_check_profile($profile): array { * * @return int * The highest severity in the array. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::getMaxSeverity() + * instead. + * + * @see https://www.drupal.org/node/3410939 */ function drupal_requirements_severity(&$requirements) { - $severity = REQUIREMENT_OK; - foreach ($requirements as $requirement) { - if (isset($requirement['severity'])) { - $severity = max($severity, $requirement['severity']); - } - } - return $severity; + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use ' . RequirementSeverity::class . '::maxSeverityFromRequirements() instead. See https://www.drupal.org/node/3410939', E_USER_DEPRECATED); + return RequirementSeverity::maxSeverityFromRequirements($requirements)->value; } /** @@ -676,10 +705,10 @@ function drupal_check_module($module) { $requirements = \Drupal::moduleHandler()->invoke($module, 'requirements', ['install']) ?? []; $requirements = array_merge($requirements, install_check_class_requirements($extension)); - if (!empty($requirements) && drupal_requirements_severity($requirements) == REQUIREMENT_ERROR) { + if (!empty($requirements) && RequirementSeverity::maxSeverityFromRequirements($requirements) === RequirementSeverity::Error) { // Print any error messages foreach ($requirements as $requirement) { - if (isset($requirement['severity']) && $requirement['severity'] == REQUIREMENT_ERROR) { + if (isset($requirement['severity']) && $requirement['severity'] === RequirementSeverity::Error) { $message = $requirement['description']; if (isset($requirement['value']) && $requirement['value']) { $message = t('@requirements_message (Currently using @item version @version)', ['@requirements_message' => $requirement['description'], '@item' => $requirement['title'], '@version' => $requirement['value']]); diff --git a/core/includes/update.inc b/core/includes/update.inc index b4ccf9f0ad0..67f6fd9c95d 100644 --- a/core/includes/update.inc +++ b/core/includes/update.inc @@ -10,6 +10,7 @@ use Drupal\Component\Graph\Graph; use Drupal\Core\Extension\Exception\UnknownExtensionException; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Utility\Error; use Drupal\Core\Update\EquivalentUpdate; @@ -34,7 +35,7 @@ function update_system_schema_requirements(): array { else { $requirements['minimum schema'] += [ 'value' => 'The installed schema version does not meet the minimum.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => 'Your system schema version is ' . $system_schema . '. Updating directly from a schema version prior to 8000 is not supported. You must upgrade your site to Drupal 8 first, see https://www.drupal.org/docs/8/upgrade.', ]; } diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php index 63514b60eff..9b6dc876501 100644 --- a/core/lib/Drupal.php +++ b/core/lib/Drupal.php @@ -76,7 +76,7 @@ class Drupal { /** * The current system version. */ - const VERSION = '11.2-dev'; + const VERSION = '11.3-dev'; /** * Core API compatibility. diff --git a/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php b/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php index e11003efcb2..86408c23390 100644 --- a/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php +++ b/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php @@ -142,7 +142,11 @@ abstract class LazyPluginCollection implements \IteratorAggregate, \Countable { $this->remove($instance_id); } - public function getIterator(): \ArrayIterator { + /** + * @return \Traversable<string, mixed> + * A traversable generator. + */ + public function getIterator(): \Traversable { $instances = []; foreach ($this->getInstanceIds() as $instance_id) { $instances[$instance_id] = $this->get($instance_id); diff --git a/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php b/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php index 6c02649d270..dad8bc10a21 100644 --- a/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php +++ b/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php @@ -7,7 +7,7 @@ use Drupal\Component\Assertion\Inspector; /** * Passes cache tag events to classes that wish to respond to them. */ -class CacheTagsInvalidator implements CacheTagsInvalidatorInterface { +class CacheTagsInvalidator implements CacheTagsInvalidatorInterface, CacheTagsPurgeInterface { /** * Holds an array of cache tags invalidators. @@ -54,6 +54,17 @@ class CacheTagsInvalidator implements CacheTagsInvalidatorInterface { } /** + * {@inheritdoc} + */ + public function purge(): void { + foreach ($this->invalidators as $invalidator) { + if ($invalidator instanceof CacheTagsPurgeInterface) { + $invalidator->purge(); + } + } + } + + /** * Adds a cache tags invalidator. * * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator diff --git a/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php b/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php new file mode 100644 index 00000000000..24c110372d1 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Cache; + +/** + * Provides purging of cache tag invalidations. + * + * Backends that persistently store cache tag invalidations can use this + * interface to implement purging of cache tag invalidations. By default, cache + * tag purging will only be called during drupal_flush_all_caches(), after all + * other caches have been cleared. + * + * @ingroup cache + */ +interface CacheTagsPurgeInterface { + + /** + * Purge cache tag invalidations. + */ + public function purge(): void; + +} diff --git a/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php b/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php index cb88c69495a..9602bc8ba5d 100644 --- a/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php +++ b/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php @@ -8,7 +8,7 @@ use Drupal\Core\Database\DatabaseException; /** * Cache tags invalidations checksum implementation that uses the database. */ -class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTagsInvalidatorInterface, CacheTagsChecksumPreloadInterface { +class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTagsInvalidatorInterface, CacheTagsChecksumPreloadInterface, CacheTagsPurgeInterface { use CacheTagsChecksumTrait; @@ -70,6 +70,22 @@ class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTags } /** + * {@inheritdoc} + */ + public function purge(): void { + try { + $this->connection->truncate('cachetags')->execute(); + } + catch (\Throwable $e) { + // If the table does not exist yet, there is nothing to purge. + if (!$this->ensureTableExists()) { + throw $e; + } + } + $this->reset(); + } + + /** * Check if the cache tags table exists and create it if not. */ protected function ensureTableExists() { diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 489b2f5f94d..133dc99f182 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -433,7 +433,7 @@ abstract class Connection { public function prepareStatement(string $query, array $options, bool $allow_row_count = FALSE): StatementInterface { assert(!isset($options['return']), 'Passing "return" option to prepareStatement() has no effect. See https://www.drupal.org/node/3185520'); if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } try { @@ -654,7 +654,7 @@ abstract class Connection { assert(!isset($options['return']), 'Passing "return" option to query() has no effect. See https://www.drupal.org/node/3185520'); assert(!isset($options['target']), 'Passing "target" option to query() has no effect. See https://www.drupal.org/node/2993033'); if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } // Use default values if not already set. diff --git a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php index 3e1f104c9f4..f477b466a63 100644 --- a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php +++ b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php @@ -12,7 +12,7 @@ trait PdoTrait { /** * Converts a FetchAs mode to a \PDO::FETCH_* constant value. * - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * The FetchAs mode. * * @return int @@ -34,7 +34,7 @@ trait PdoTrait { * @param int $mode * The \PDO::FETCH_* constant value. * - * @return \Drupal\Core\Database\FetchAs + * @return \Drupal\Core\Database\Statement\FetchAs * A FetchAs mode. */ protected function pdoToFetchAs(int $mode): FetchAs { @@ -70,7 +70,7 @@ trait PdoTrait { /** * Sets the default fetch mode for the PDO statement. * - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * One of the cases of the FetchAs enum. * @param int|class-string|null $columnOrClass * If $mode is FetchAs::Column, the index of the column to fetch. @@ -118,7 +118,7 @@ trait PdoTrait { /** * Fetches the next row from the PDO statement. * - * @param \Drupal\Core\Database\FetchAs|null $mode + * @param \Drupal\Core\Database\Statement\FetchAs|null $mode * (Optional) one of the cases of the FetchAs enum. If not specified, * defaults to what is specified by setFetchMode(). * @param int|null $cursorOrientation @@ -175,7 +175,7 @@ trait PdoTrait { /** * Returns an array containing all of the result set rows. * - * @param \Drupal\Core\Database\FetchAs|null $mode + * @param \Drupal\Core\Database\Statement\FetchAs|null $mode * (Optional) one of the cases of the FetchAs enum. If not specified, * defaults to what is specified by setFetchMode(). * @param int|class-string|null $columnOrClass diff --git a/core/lib/Drupal/Core/Database/Statement/ResultBase.php b/core/lib/Drupal/Core/Database/Statement/ResultBase.php index 6232581f906..af1b12a5653 100644 --- a/core/lib/Drupal/Core/Database/Statement/ResultBase.php +++ b/core/lib/Drupal/Core/Database/Statement/ResultBase.php @@ -42,7 +42,7 @@ abstract class ResultBase { /** * Sets the default fetch mode for this result set. * - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * One of the cases of the FetchAs enum. * @param array{class: class-string, constructor_args: list<mixed>, column: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions * An array of fetch options. @@ -55,7 +55,7 @@ abstract class ResultBase { /** * Fetches the next row. * - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * One of the cases of the FetchAs enum. * @param array{class?: class-string, constructor_args?: list<mixed>, column?: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions * An array of fetch options. @@ -68,7 +68,7 @@ abstract class ResultBase { /** * Returns an array containing all of the result set rows. * - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * One of the cases of the FetchAs enum. * @param array{class?: class-string, constructor_args?: list<mixed>, column?: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions * An array of fetch options. @@ -120,7 +120,7 @@ abstract class ResultBase { * * @param string $column * The name of the field on which to index the array. - * @param \Drupal\Core\Database\FetchAs $mode + * @param \Drupal\Core\Database\Statement\FetchAs $mode * One of the cases of the FetchAs enum. If set to FetchAs::Associative * or FetchAs::List the returned value with be an array of arrays. For any * other value it will be an array of objects. If not specified, defaults to diff --git a/core/lib/Drupal/Core/Database/Statement/StatementBase.php b/core/lib/Drupal/Core/Database/Statement/StatementBase.php index c193c5d3502..98fa378d58f 100644 --- a/core/lib/Drupal/Core/Database/Statement/StatementBase.php +++ b/core/lib/Drupal/Core/Database/Statement/StatementBase.php @@ -180,7 +180,7 @@ abstract class StatementBase implements \Iterator, StatementInterface { */ public function setFetchMode($mode, $a1 = NULL, $a2 = []) { if (is_int($mode)) { - @trigger_error("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); $mode = $this->pdoToFetchAs($mode); } assert($mode instanceof FetchAs); @@ -217,7 +217,7 @@ abstract class StatementBase implements \Iterator, StatementInterface { */ public function fetch($mode = NULL, $cursorOrientation = NULL, $cursorOffset = NULL) { if (is_int($mode)) { - @trigger_error("Passing the \$mode argument as an integer to fetch() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the \$mode argument as an integer to fetch() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); $mode = $this->pdoToFetchAs($mode); } assert($mode === NULL || $mode instanceof FetchAs); @@ -292,7 +292,7 @@ abstract class StatementBase implements \Iterator, StatementInterface { */ public function fetchAll($mode = NULL, $columnIndex = NULL, $constructorArguments = NULL) { if (is_int($mode)) { - @trigger_error("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); $mode = $this->pdoToFetchAs($mode); } @@ -325,7 +325,7 @@ abstract class StatementBase implements \Iterator, StatementInterface { */ public function fetchAllAssoc($key, $fetch = NULL) { if (is_int($fetch)) { - @trigger_error("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); $fetch = $this->pdoToFetchAs($fetch); } assert($fetch === NULL || $fetch instanceof FetchAs); diff --git a/core/lib/Drupal/Core/Database/StatementInterface.php b/core/lib/Drupal/Core/Database/StatementInterface.php index c4cafb9d289..7f906620674 100644 --- a/core/lib/Drupal/Core/Database/StatementInterface.php +++ b/core/lib/Drupal/Core/Database/StatementInterface.php @@ -65,7 +65,7 @@ interface StatementInterface extends \Traversable { /** * Sets the default fetch mode for this statement. * - * @param \Drupal\Core\Database\FetchAs|int $mode + * @param \Drupal\Core\Database\Statement\FetchAs|int $mode * One of the cases of the FetchAs enum, or (deprecated) a \PDO::FETCH_* * constant. * @param string|int|null $a1 @@ -87,7 +87,7 @@ interface StatementInterface extends \Traversable { /** * Fetches the next row from a result set. * - * @param \Drupal\Core\Database\FetchAs|int|null $mode + * @param \Drupal\Core\Database\Statement\FetchAs|int|null $mode * (Optional) one of the cases of the FetchAs enum, or (deprecated) a * \PDO::FETCH_* constant. If not specified, defaults to what is specified * by setFetchMode(). @@ -147,7 +147,7 @@ interface StatementInterface extends \Traversable { /** * Returns an array containing all of the result set rows. * - * @param \Drupal\Core\Database\FetchAs|int|null $mode + * @param \Drupal\Core\Database\Statement\FetchAs|int|null $mode * (Optional) one of the cases of the FetchAs enum, or (deprecated) a * \PDO::FETCH_* constant. If not specified, defaults to what is specified * by setFetchMode(). @@ -206,7 +206,7 @@ interface StatementInterface extends \Traversable { * * @param string $key * The name of the field on which to index the array. - * @param \Drupal\Core\Database\FetchAs|int|string|null $fetch + * @param \Drupal\Core\Database\Statement\FetchAs|int|string|null $fetch * (Optional) the fetch mode to use. One of the cases of the FetchAs enum, * or (deprecated) a \PDO::FETCH_* constant. If set to FetchAs::Associative * or FetchAs::List the returned value with be an array of arrays. For any diff --git a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php index 96bc07e7f89..8a2a73f1bf7 100644 --- a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php +++ b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php @@ -101,7 +101,7 @@ class StatementPrefetchIterator extends StatementBase { */ public function execute($args = [], $options = []) { if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } $startEvent = $this->dispatchStatementExecutionStartEvent($args ?? []); diff --git a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php index 88dc007f540..f580d645cad 100644 --- a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php +++ b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php @@ -52,7 +52,7 @@ class StatementWrapperIterator extends StatementBase { */ public function execute($args = [], $options = []) { if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } if (isset($options['fetch'])) { diff --git a/core/lib/Drupal/Core/Database/Transaction.php b/core/lib/Drupal/Core/Database/Transaction.php index dcecc44e17c..b8693e4bb76 100644 --- a/core/lib/Drupal/Core/Database/Transaction.php +++ b/core/lib/Drupal/Core/Database/Transaction.php @@ -30,12 +30,12 @@ class Transaction { /** * Destructs the object. * - * Depending on the nesting level of the object, this leads to a COMMIT (for - * a root item) or to a RELEASE SAVEPOINT (for a savepoint item) executed on - * the database. + * If the transaction is still active at this stage, and depending on the + * state of the transaction stack, this leads to a COMMIT (for a root item) + * or to a RELEASE SAVEPOINT (for a savepoint item) executed on the database. */ public function __destruct() { - $this->connection->transactionManager()->unpile($this->name, $this->id); + $this->connection->transactionManager()->purge($this->name, $this->id); } /** @@ -46,16 +46,22 @@ class Transaction { } /** - * Rolls back the current transaction. + * Returns the transaction to the parent nesting level. * - * This is just a wrapper method to rollback whatever transaction stack we are - * currently in, which is managed by the TransactionManager. Note that logging - * needs to happen after a transaction has been rolled back or the log - * messages will be rolled back too. + * Depending on the state of the transaction stack, this leads to a COMMIT + * operation (for a root item), or to a RELEASE SAVEPOINT operation (for a + * savepoint item) executed on the database. + */ + public function commitOrRelease(): void { + $this->connection->transactionManager()->unpile($this->name, $this->id); + } + + /** + * Rolls back the transaction. * - * Depending on the nesting level of the object, this leads to a ROLLBACK (for - * a root item) or to a ROLLBACK TO SAVEPOINT (for a savepoint item) executed - * on the database. + * Depending on the state of the transaction stack, this leads to a ROLLBACK + * operation (for a root item), or to a ROLLBACK TO SAVEPOINT + a RELEASE + * SAVEPOINT operations (for a savepoint item) executed on the database. */ public function rollBack() { $this->connection->transactionManager()->rollback($this->name, $this->id); diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php index aa663d94226..fa1a309a767 100644 --- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php +++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php @@ -102,6 +102,16 @@ abstract class TransactionManagerBase implements TransactionManagerInterface { private ClientConnectionTransactionState $connectionTransactionState; /** + * Whether to trigger warnings when unpiling a void transaction. + * + * Normally FALSE, is set to TRUE by specific tests checking the internal + * state of the transaction stack. + * + * @internal + */ + public bool $triggerWarningWhenUnpilingOnVoidTransaction = FALSE; + + /** * Constructor. * * @param \Drupal\Core\Database\Connection $connection @@ -202,7 +212,9 @@ abstract class TransactionManagerBase implements TransactionManagerInterface { protected function voidStackItem(string $id): void { // The item should be removed from $stack and added to $voidedItems for // later processing. - $this->voidedItems[$id] = $this->stack[$id]; + if (isset($this->stack[$id])) { + $this->voidedItems[$id] = $this->stack[$id]; + } $this->removeStackItem($id); } @@ -285,14 +297,29 @@ abstract class TransactionManagerBase implements TransactionManagerInterface { } /** - * {@inheritdoc} + * Purges a Drupal transaction from the manager. + * + * This is only called by a Transaction object's ::__destruct() method and + * should only be called internally by a database driver. + * + * @param string $name + * The name of the transaction. + * @param string $id + * The id of the transaction. + * + * @throws \Drupal\Core\Database\TransactionOutOfOrderException + * If a Drupal Transaction with the specified name does not exist. + * @throws \Drupal\Core\Database\TransactionCommitFailedException + * If the commit of the root transaction failed. + * + * @internal */ - public function unpile(string $name, string $id): void { + public function purge(string $name, string $id): void { // If this is a 'root' transaction, and it is voided (that is, no longer in // the stack), then the transaction on the database is no longer active. An - // action such as a rollback, or a DDL statement, was executed that - // terminated the database transaction. So, we can process the post - // transaction callbacks. + // action such as a commit, a release savepoint, a rollback, or a DDL + // statement, was executed that terminated the database transaction. So, we + // can process the post transaction callbacks. if (!isset($this->stack()[$id]) && isset($this->voidedItems[$id]) && $this->rootId === $id) { $this->processPostTransactionCallbacks(); $this->rootId = NULL; @@ -309,6 +336,62 @@ abstract class TransactionManagerBase implements TransactionManagerInterface { return; } + // When we get here, the transaction (or savepoint) is still active on the + // database. We can unpile it, and if we are left with no more items in the + // stack, we can also process the post transaction callbacks. + $this->commit($name, $id); + $this->removeStackItem($id); + if ($this->rootId === $id) { + $this->processPostTransactionCallbacks(); + $this->rootId = NULL; + } + } + + /** + * {@inheritdoc} + */ + public function unpile(string $name, string $id): void { + // If the transaction was voided, we cannot unpile. Skip but trigger a user + // warning if requested. + if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Voided) { + if ($this->triggerWarningWhenUnpilingOnVoidTransaction) { + trigger_error('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', E_USER_WARNING); + } + return; + } + + // If there is no $id to commit, or if $id does not correspond to the one + // in the stack for that $name, the commit is out of order. + if (!isset($this->stack()[$id]) || $this->stack()[$id]->name !== $name) { + throw new TransactionOutOfOrderException("Error attempting commit of {$id}\\{$name}. Active stack: " . $this->dumpStackItemsAsString()); + } + + // Commit the transaction. + $this->commit($name, $id); + + // Void the transaction stack item. + $this->voidStackItem($id); + } + + /** + * Commits a Drupal transaction. + * + * @param string $name + * The name of the transaction. + * @param string $id + * The id of the transaction. + * + * @throws \Drupal\Core\Database\TransactionOutOfOrderException + * If a Drupal Transaction with the specified name does not exist. + * @throws \Drupal\Core\Database\TransactionCommitFailedException + * If the commit of the root transaction failed. + */ + protected function commit(string $name, string $id): void { + if ($this->getConnectionTransactionState() !== ClientConnectionTransactionState::Active) { + // The stack got corrupted. + throw new TransactionOutOfOrderException("Transaction {$id}\\{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString()); + } + // If we are not releasing the last savepoint but an earlier one, or // committing a root transaction while savepoints are active, all // subsequent savepoints will be released as well. The stack must be @@ -317,33 +400,20 @@ abstract class TransactionManagerBase implements TransactionManagerInterface { $this->voidStackItem((string) $i); } - if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Active) { - if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) { - // Release the client transaction savepoint in case the Drupal - // transaction is not a root one. - $this->releaseClientSavepoint($name); - } - elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) { - // If this was the root Drupal transaction, we can commit the client - // transaction. - $this->processRootCommit(); - if ($this->rootId === $id) { - $this->processPostTransactionCallbacks(); - $this->rootId = NULL; - } - } - else { - // The stack got corrupted. - throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString()); - } - - // Remove the transaction from the stack. - $this->removeStackItem($id); - return; + if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) { + // Release the client transaction savepoint in case the Drupal + // transaction is not a root one. + $this->releaseClientSavepoint($name); + } + elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) { + // If this was the root Drupal transaction, we can commit the client + // transaction. + $this->processRootCommit(); + } + else { + // The stack got corrupted. + throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString()); } - - // The stack got corrupted. - throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString()); } /** diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php index 11af511f14b..a9aa2c77052 100644 --- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php +++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php @@ -53,8 +53,8 @@ interface TransactionManagerInterface { * Removes a Drupal transaction from the stack. * * The unpiled item does not necessarily need to be the last on the stack. - * This method should only be called by a Transaction object going out of - * scope. + * This method should only be called by a Transaction object's + * ::commitOrRelease() method. * * This method should only be called internally by a database driver. * diff --git a/core/lib/Drupal/Core/Extension/InstallRequirementsInterface.php b/core/lib/Drupal/Core/Extension/InstallRequirementsInterface.php index f2e8d7137c8..c40c9b830fa 100644 --- a/core/lib/Drupal/Core/Extension/InstallRequirementsInterface.php +++ b/core/lib/Drupal/Core/Extension/InstallRequirementsInterface.php @@ -20,8 +20,8 @@ interface InstallRequirementsInterface { * hand. As a consequence, install-time requirements must be checked without * access to the full Drupal API, because it is not available during * install.php. - * If a requirement has a severity of REQUIREMENT_ERROR, install.php will - * abort or at least the module will not install. + * If a requirement has a severity of RequirementSeverity::Error, install.php + * will abort or at least the module will not install. * Other severity levels have no effect on the installation. * Module dependencies do not belong to these installation requirements, * but should be defined in the module's .info.yml file. @@ -37,12 +37,9 @@ interface InstallRequirementsInterface { * - value: This should only be used for version numbers, do not set it if * not applicable. * - description: The description of the requirement/status. - * - severity: (optional) The requirement's result/severity level, one of: - * - REQUIREMENT_INFO: For info only. - * - REQUIREMENT_OK: The requirement is satisfied. - * - REQUIREMENT_WARNING: The requirement failed with a warning. - * - REQUIREMENT_ERROR: The requirement failed with an error. - * Defaults to REQUIREMENT_OK when installing. + * - severity: (optional) An instance of + * \Drupal\Core\Extension\Requirement\RequirementSeverity enum. Defaults + * to RequirementSeverity::OK when installing. */ public static function getRequirements(): array; diff --git a/core/lib/Drupal/Core/Extension/Requirement/RequirementSeverity.php b/core/lib/Drupal/Core/Extension/Requirement/RequirementSeverity.php new file mode 100644 index 00000000000..ec085c0cb5b --- /dev/null +++ b/core/lib/Drupal/Core/Extension/Requirement/RequirementSeverity.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Extension\Requirement; + +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * The requirements severity enum. + */ +enum RequirementSeverity: int { + + /* + * Informational message only. + */ + case Info = -1; + + /* + * Requirement successfully met. + */ + case OK = 0; + + /* + * Warning condition; proceed but flag warning. + */ + case Warning = 1; + + /* + * Error condition; abort installation. + */ + case Error = 2; + + /** + * Returns the translated title of the severity. + */ + public function title(): TranslatableMarkup { + return match ($this) { + self::Info => new TranslatableMarkup('Checked'), + self::OK => new TranslatableMarkup('OK'), + self::Warning => new TranslatableMarkup('Warnings found'), + self::Error => new TranslatableMarkup('Errors found'), + }; + } + + /** + * Returns the status of the severity. + * + * This string representation can be used as an array key when grouping + * requirements checks by severity, or in other places where the int-backed + * value is not appropriate. + */ + public function status(): string { + return match ($this) { + self::Info => 'checked', + self::OK => 'ok', + self::Warning => 'warning', + self::Error => 'error', + }; + + } + + /** + * Determines the most severe requirement in a list of requirements. + * + * @param array<string, array{'title': \Drupal\Core\StringTranslation\TranslatableMarkup, 'value': mixed, description: \Drupal\Core\StringTranslation\TranslatableMarkup, 'severity': \Drupal\Core\Extension\Requirement\RequirementSeverity}> $requirements + * An array of requirements, in the same format as is returned by + * hook_requirements(), hook_runtime_requirements(), + * hook_update_requirements(), and + * \Drupal\Core\Extension\InstallRequirementsInterface. + * + * @return \Drupal\Core\Extension\Requirement\RequirementSeverity + * The most severe requirement. + * + * @see \Drupal\Core\Extension\InstallRequirementsInterface::getRequirements() + * @see \hook_requirements() + * @see \hook_runtime_requirements() + * @see \hook_update_requirements() + */ + public static function maxSeverityFromRequirements(array $requirements): RequirementSeverity { + RequirementSeverity::convertLegacyIntSeveritiesToEnums($requirements, __METHOD__); + return array_reduce( + $requirements, + function (RequirementSeverity $severity, $requirement) { + $requirementSeverity = $requirement['severity'] ?? RequirementSeverity::OK; + return RequirementSeverity::from(max($severity->value, $requirementSeverity->value)); + }, + RequirementSeverity::OK + ); + } + + /** + * Converts legacy int value severities to enums. + * + * @param array<string, array{'title': \Drupal\Core\StringTranslation\TranslatableMarkup, 'value': mixed, description: \Drupal\Core\StringTranslation\TranslatableMarkup, 'severity': \Drupal\Core\Extension\Requirement\RequirementSeverity}> $requirements + * An array of requirements, in the same format as is returned by + * hook_requirements(), hook_runtime_requirements(), + * hook_update_requirements(), and + * \Drupal\Core\Extension\InstallRequirementsInterface. + * @param string $deprecationMethod + * The method name to pass to the deprecation message. + * + * @see \Drupal\Core\Extension\InstallRequirementsInterface::getRequirements() + * @see \hook_requirements() + * @see \hook_runtime_requirements() + * @see \hook_update_requirements() + */ + public static function convertLegacyIntSeveritiesToEnums(array &$requirements, string $deprecationMethod): void { + foreach ($requirements as &$requirement) { + if (isset($requirement['severity'])) { + $severity = $requirement['severity']; + if (!$severity instanceof RequirementSeverity) { + @trigger_error("Calling {$deprecationMethod}() with an array of \$requirements with 'severity' with values not of type " . RequirementSeverity::class . " enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939", \E_USER_DEPRECATED); + $requirement['severity'] = RequirementSeverity::from($requirement['severity']); + } + } + } + } + +} diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php index 364d672c12f..172193ca855 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Extension; +use Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface; use Drupal\Component\Utility\Html; use Drupal\Core\Asset\AssetCollectionOptimizerInterface; use Drupal\Core\Config\ConfigFactoryInterface; @@ -22,100 +23,25 @@ class ThemeInstaller implements ThemeInstallerInterface { use ModuleDependencyMessageTrait; use StringTranslationTrait; - /** - * @var \Drupal\Core\Extension\ThemeHandlerInterface - */ - protected $themeHandler; - - /** - * @var \Drupal\Core\Config\ConfigFactoryInterface - */ - protected $configFactory; - - /** - * @var \Drupal\Core\Config\ConfigInstallerInterface - */ - protected $configInstaller; - - /** - * @var \Drupal\Core\Extension\ModuleHandlerInterface - */ - protected $moduleHandler; - - /** - * @var \Drupal\Core\State\StateInterface - */ - protected $state; - - /** - * @var \Drupal\Core\Config\ConfigManagerInterface - */ - protected $configManager; - - /** - * @var \Drupal\Core\Asset\AssetCollectionOptimizerInterface - */ - protected $cssCollectionOptimizer; - - /** - * @var \Drupal\Core\Routing\RouteBuilderInterface - */ - protected $routeBuilder; - - /** - * @var \Psr\Log\LoggerInterface - */ - protected $logger; - - /** - * The module extension list. - * - * @var \Drupal\Core\Extension\ModuleExtensionList - */ - protected $moduleExtensionList; - - /** - * Constructs a new ThemeInstaller. - * - * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler - * The theme handler. - * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory - * The config factory to get the installed themes. - * @param \Drupal\Core\Config\ConfigInstallerInterface $config_installer - * (optional) The config installer to install configuration. This optional - * to allow the theme handler to work before Drupal is installed and has a - * database. - * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler - * The module handler to fire themes_installed/themes_uninstalled hooks. - * @param \Drupal\Core\Config\ConfigManagerInterface $config_manager - * The config manager used to uninstall a theme. - * @param \Drupal\Core\Asset\AssetCollectionOptimizerInterface $css_collection_optimizer - * The CSS asset collection optimizer service. - * @param \Drupal\Core\Routing\RouteBuilderInterface $route_builder - * (optional) The route builder service to rebuild the routes if a theme is - * installed. - * @param \Psr\Log\LoggerInterface $logger - * A logger instance. - * @param \Drupal\Core\State\StateInterface $state - * The state store. - * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list - * The module extension list. - * @param \Drupal\Core\Theme\Registry|null $themeRegistry - * The theme registry. - * @param \Drupal\Core\Extension\ThemeExtensionList|null $themeExtensionList - * The theme extension list. - */ - public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state, ModuleExtensionList $module_extension_list, protected Registry $themeRegistry, protected ThemeExtensionList $themeExtensionList) { - $this->themeHandler = $theme_handler; - $this->configFactory = $config_factory; - $this->configInstaller = $config_installer; - $this->moduleHandler = $module_handler; - $this->configManager = $config_manager; - $this->cssCollectionOptimizer = $css_collection_optimizer; - $this->routeBuilder = $route_builder; - $this->logger = $logger; - $this->state = $state; - $this->moduleExtensionList = $module_extension_list; + public function __construct( + protected readonly ThemeHandlerInterface $themeHandler, + protected readonly ConfigFactoryInterface $configFactory, + protected readonly ConfigInstallerInterface $configInstaller, + protected readonly ModuleHandlerInterface $moduleHandler, + protected readonly ConfigManagerInterface $configManager, + protected readonly AssetCollectionOptimizerInterface $cssCollectionOptimizer, + protected readonly RouteBuilderInterface $routeBuilder, + protected readonly LoggerInterface $logger, + protected readonly StateInterface $state, + protected readonly ModuleExtensionList $moduleExtensionList, + protected readonly Registry $themeRegistry, + protected readonly ThemeExtensionList $themeExtensionList, + protected ?CachedDiscoveryInterface $componentPluginManager = NULL, + ) { + if ($this->componentPluginManager === NULL) { + @trigger_error('Calling ' . __METHOD__ . ' without the $componentPluginManager argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3525649', E_USER_DEPRECATED); + $this->componentPluginManager = \Drupal::service('plugin.manager.sdc'); + } } /** @@ -311,11 +237,9 @@ class ThemeInstaller implements ThemeInstallerInterface { * Resets some other systems like rebuilding the route information or caches. */ protected function resetSystem() { - if ($this->routeBuilder) { - $this->routeBuilder->setRebuildNeeded(); - } - + $this->routeBuilder->setRebuildNeeded(); $this->themeRegistry->reset(); + $this->componentPluginManager->clearCachedDefinitions(); } } diff --git a/core/lib/Drupal/Core/Extension/module.api.php b/core/lib/Drupal/Core/Extension/module.api.php index 40cd1824106..4d8d0a863a3 100644 --- a/core/lib/Drupal/Core/Extension/module.api.php +++ b/core/lib/Drupal/Core/Extension/module.api.php @@ -6,6 +6,7 @@ */ use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Link; use Drupal\Core\Url; use Drupal\Core\Utility\UpdateException; @@ -1097,8 +1098,9 @@ function hook_updater_info_alter(&$updaters) { * Drupal itself (by install.php) with an installation profile or later by hand. * As a consequence, install-time requirements must be checked without access * to the full Drupal API, because it is not available during install.php. - * If a requirement has a severity of REQUIREMENT_ERROR, install.php will abort - * or at least the module will not install. + * If a requirement has a severity of + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Error, install.php + * will abort or at least the module will not install. * Other severity levels have no effect on the installation. * Module dependencies do not belong to these installation requirements, * but should be defined in the module's .info.yml file. @@ -1111,8 +1113,9 @@ function hook_updater_info_alter(&$updaters) { * tasks and security issues. * The returned 'requirements' will be listed on the status report in the * administration section, with indication of the severity level. - * Moreover, any requirement with a severity of REQUIREMENT_ERROR severity will - * result in a notice on the administration configuration page. + * Moreover, any requirement with a severity of + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Error will result in + * a notice on the administration configuration page. * * @param string $phase * The phase in which requirements are checked: @@ -1121,7 +1124,7 @@ function hook_updater_info_alter(&$updaters) { * - runtime: The runtime requirements are being checked and shown on the * status report page. * - * @return array + * @return array<string, array{'title': \Drupal\Core\StringTranslation\TranslatableMarkup, 'value': mixed, description: \Drupal\Core\StringTranslation\TranslatableMarkup, 'severity': \Drupal\Core\Extension\Requirement\RequirementSeverity}> * An associative array where the keys are arbitrary but must be unique (it * is suggested to use the module short name as a prefix) and the values are * themselves associative arrays with the following elements: @@ -1130,12 +1133,9 @@ function hook_updater_info_alter(&$updaters) { * install phase, this should only be used for version numbers, do not set * it if not applicable. * - description: The description of the requirement/status. - * - severity: (optional) The requirement's result/severity level, one of: - * - REQUIREMENT_INFO: For info only. - * - REQUIREMENT_OK: The requirement is satisfied. - * - REQUIREMENT_WARNING: The requirement failed with a warning. * - REQUIREMENT_ERROR: The requirement failed with an error. - * Defaults to REQUIREMENT_OK when installing, REQUIREMENT_INFO otherwise. + * - severity: The requirement's severity. Defaults to RequirementSeverity::OK + * when installing, or RequirementSeverity::Info otherwise. */ function hook_requirements($phase): array { $requirements = []; @@ -1145,7 +1145,7 @@ function hook_requirements($phase): array { $requirements['drupal'] = [ 'title' => t('Drupal'), 'value' => \Drupal::VERSION, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; } @@ -1156,7 +1156,7 @@ function hook_requirements($phase): array { ]; if (version_compare(phpversion(), \Drupal::MINIMUM_PHP) < 0) { $requirements['php']['description'] = t('Your PHP installation is too old. Drupal requires at least PHP %version.', ['%version' => \Drupal::MINIMUM_PHP]); - $requirements['php']['severity'] = REQUIREMENT_ERROR; + $requirements['php']['severity'] = RequirementSeverity::Error; } // Report cron status @@ -1169,7 +1169,7 @@ function hook_requirements($phase): array { else { $requirements['cron'] = [ 'description' => t('Cron has not run. It appears cron jobs have not been setup on your system. Check the help pages for <a href=":url">configuring cron jobs</a>.', [':url' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview']), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'value' => t('Never run'), ]; } @@ -1199,7 +1199,7 @@ function hook_requirements_alter(array &$requirements): void { $requirements['php']['title'] = t('PHP version'); // Decrease the 'update status' requirement severity from warning to info. - $requirements['update status']['severity'] = REQUIREMENT_INFO; + $requirements['update status']['severity'] = RequirementSeverity::Info; // Remove a requirements entry. unset($requirements['foo']); @@ -1216,8 +1216,9 @@ function hook_requirements_alter(array &$requirements): void { * general status information like maintenance tasks and security issues. * The returned requirements will be listed on the status report in the * administration section, with an indication of the severity level. - * Moreover, any requirement with a severity of REQUIREMENT_ERROR will result in - * a notice on the 'Configuration' administration page (/admin/config). + * Moreover, any requirement with severity of RequirementSeverity::Error will + * result in a notice on the 'Configuration' administration page + * (/admin/config). * * @return array * An associative array where the keys are arbitrary but must be unique (it @@ -1226,12 +1227,9 @@ function hook_requirements_alter(array &$requirements): void { * - title: The name of the requirement. * - value: The current value (e.g., version, time, level, etc). * - description: The description of the requirement/status. - * - severity: (optional) The requirement's severity level, one of: - * - REQUIREMENT_INFO: For info only. - * - REQUIREMENT_OK: The requirement is satisfied. - * - REQUIREMENT_WARNING: The requirement failed with a warning. - * - REQUIREMENT_ERROR: The requirement failed with an error. - * Defaults to REQUIREMENT_OK. + * - severity: (optional) An instance of + * \Drupal\Core\Extension\Requirement\RequirementSeverity enum. Defaults to + * RequirementSeverity::OK. */ function hook_runtime_requirements(): array { $requirements = []; @@ -1240,7 +1238,7 @@ function hook_runtime_requirements(): array { $requirements['drupal'] = [ 'title' => t('Drupal'), 'value' => \Drupal::VERSION, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; // Test PHP version @@ -1250,7 +1248,7 @@ function hook_runtime_requirements(): array { ]; if (version_compare(phpversion(), \Drupal::MINIMUM_PHP) < 0) { $requirements['php']['description'] = t('Your PHP installation is too old. Drupal requires at least PHP %version.', ['%version' => \Drupal::MINIMUM_PHP]); - $requirements['php']['severity'] = REQUIREMENT_ERROR; + $requirements['php']['severity'] = RequirementSeverity::Error; } // Report cron status @@ -1263,7 +1261,7 @@ function hook_runtime_requirements(): array { else { $requirements['cron']['description'] = t('Cron has not run. It appears cron jobs have not been setup on your system. Check the help pages for <a href=":url">configuring cron jobs</a>.', [':url' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview']); $requirements['cron']['value'] = t('Never run'); - $requirements['cron']['severity'] = REQUIREMENT_ERROR; + $requirements['cron']['severity'] = RequirementSeverity::Error; } $requirements['cron']['description'] .= ' ' . t('You can <a href=":cron">run cron manually</a>.', [':cron' => Url::fromRoute('system.run_cron')->toString()]); @@ -1287,7 +1285,7 @@ function hook_runtime_requirements_alter(array &$requirements): void { $requirements['php']['title'] = t('PHP version'); // Decrease the 'update status' requirement severity from warning to info. - $requirements['update status']['severity'] = REQUIREMENT_INFO; + $requirements['update status']['severity'] = RequirementSeverity::Info; // Remove a requirements entry. unset($requirements['foo']); @@ -1306,13 +1304,9 @@ function hook_runtime_requirements_alter(array &$requirements): void { * - title: The name of the requirement. * - value: The current value (e.g., version, time, level, etc). * - description: The description of the requirement/status. - * - severity: (optional) The requirement's result/severity level, one of: - * - REQUIREMENT_INFO: Has no effect during updates. - * - REQUIREMENT_OK: Has no effect during updates. - * - REQUIREMENT_WARNING: Displays a warning, user can choose to continue. - * - REQUIREMENT_ERROR: Displays an error message, user cannot continue - * until the problem is resolved. - * Defaults to REQUIREMENT_OK. + * - severity: (optional) An instance of + * \Drupal\Core\Extension\Requirement\RequirementSeverity enum. Defaults to + * RequirementSeverity::OK. */ function hook_update_requirements() { $requirements = []; @@ -1324,7 +1318,7 @@ function hook_update_requirements() { ]; if (version_compare(phpversion(), \Drupal::MINIMUM_PHP) < 0) { $requirements['php']['description'] = t('Your PHP installation is too old. Drupal requires at least PHP %version.', ['%version' => \Drupal::MINIMUM_PHP]); - $requirements['php']['severity'] = REQUIREMENT_ERROR; + $requirements['php']['severity'] = RequirementSeverity::Error; } return $requirements; @@ -1347,7 +1341,7 @@ function hook_update_requirements_alter(array &$requirements): void { $requirements['php']['title'] = t('PHP version'); // Decrease the 'update status' requirement severity from warning to info. - $requirements['update status']['severity'] = REQUIREMENT_INFO; + $requirements['update status']['severity'] = RequirementSeverity::Info; // Remove a requirements entry. unset($requirements['foo']); diff --git a/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php b/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php deleted file mode 100644 index 158010463d2..00000000000 --- a/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php +++ /dev/null @@ -1,54 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Core\Hook\Attribute; - -use Drupal\Core\Hook\Order\OrderInterface; - -/** - * Hook attribute for FormAlter. - * - * @see hook_form_alter(). - */ -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -class FormAlter extends Hook { - - /** - * {@inheritdoc} - */ - public const string PREFIX = 'form'; - - /** - * {@inheritdoc} - */ - public const string SUFFIX = 'alter'; - - /** - * Constructs a FormAlter attribute object. - * - * @param string $form_id - * (optional) The ID of the form that this implementation alters. - * If this is left blank then `form_alter` is the hook that is registered. - * @param string $method - * (optional) The method name. If this attribute is on a method, this - * parameter is not required. If this attribute is on a class and this - * parameter is omitted, the class must have an __invoke() method, which is - * taken as the hook implementation. - * @param string|null $module - * (optional) The module this implementation is for. This allows one module - * to implement a hook on behalf of another module. Defaults to the module - * the implementation is in. - * @param \Drupal\Core\Hook\Order\OrderInterface|null $order - * (optional) Set the order of the implementation. - */ - public function __construct( - string $form_id = '', - public string $method = '', - public ?string $module = NULL, - public ?OrderInterface $order = NULL, - ) { - parent::__construct($form_id, $method, $module, $order); - } - -} diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php index 34dbc8ebf91..0084e651180 100644 --- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php +++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php @@ -98,27 +98,10 @@ use Drupal\Core\Hook\Order\OrderInterface; class Hook implements HookAttributeInterface { /** - * The hook prefix such as `form`. - * - * @var string - */ - public const string PREFIX = ''; - - /** - * The hook suffix such as `alter`. - * - * @var string - */ - public const string SUFFIX = ''; - - /** * Constructs a Hook attribute object. * * @param string $hook * The short hook name, without the 'hook_' prefix. - * $hook is only optional when Hook is extended and a PREFIX or SUFFIX is - * defined. When using the [#Hook] attribute directly $hook is required. - * See Drupal\Core\Hook\Attribute\Preprocess. * @param string $method * (optional) The method name. If this attribute is on a method, this * parameter is not required. If this attribute is on a class and this @@ -132,15 +115,10 @@ class Hook implements HookAttributeInterface { * (optional) Set the order of the implementation. */ public function __construct( - public string $hook = '', + public string $hook, public string $method = '', public ?string $module = NULL, public ?OrderInterface $order = NULL, - ) { - $this->hook = implode('_', array_filter([static::PREFIX, $hook, static::SUFFIX])); - if ($this->hook === '') { - throw new \LogicException('The Hook attribute or an attribute extending the Hook attribute must provide the $hook parameter, a PREFIX or a SUFFIX.'); - } - } + ) {} } diff --git a/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php b/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php deleted file mode 100644 index 47642859a20..00000000000 --- a/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php +++ /dev/null @@ -1,23 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Core\Hook\Attribute; - -/** - * Attribute for defining a class method as a preprocess function. - * - * Pass no arguments for hook_preprocess `#[Preprocess]`. - * For `hook_preprocess_HOOK` pass the `HOOK` without the `hook_preprocess` - * portion `#[Preprocess('HOOK')]`. - * - * See \Drupal\Core\Hook\Attribute\Hook for additional information. - */ -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -class Preprocess extends Hook { - /** - * {@inheritdoc} - */ - public const string PREFIX = 'preprocess'; - -} diff --git a/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php b/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php new file mode 100644 index 00000000000..84dfb64c7b2 --- /dev/null +++ b/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Mailer\Transport; + +use Drupal\Core\Site\Settings; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * Command validation decorator for sendmail transport factory. + */ +class SendmailCommandValidationTransportFactory implements TransportFactoryInterface { + + /** + * Construct command validation decorator for sendmail transport factory. + * + * @param \Symfony\Component\Mailer\Transport\TransportFactoryInterface $inner + * The decorated sendmail transport factory. + */ + public function __construct( + #[AutowireDecorated] + protected TransportFactoryInterface $inner, + ) { + } + + /** + * {@inheritdoc} + */ + public function create(Dsn $dsn): TransportInterface { + $command = $dsn->getOption('command'); + if (!empty($command)) { + $commands = Settings::get('mailer_sendmail_commands', []); + if (!in_array($command, $commands, TRUE)) { + throw new \RuntimeException("Unsafe sendmail command {$command}"); + } + } + + return $this->inner->create($dsn); + } + + /** + * {@inheritdoc} + */ + public function supports(Dsn $dsn): bool { + return $this->inner->supports($dsn); + } + +} diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php new file mode 100644 index 00000000000..8950d44e364 --- /dev/null +++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Mailer; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * The default mailer transport service factory. + */ +class TransportServiceFactory implements TransportServiceFactoryInterface { + + use TransportServiceFactoryTrait; + + /** + * Constructs a new transport service factory. + * + * @param Iterable<TransportFactoryInterface> $factories + * A list of transport factories. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + #[AutowireIterator(tag: 'mailer.transport_factory')] + iterable $factories, + protected ConfigFactoryInterface $configFactory, + ) { + $this->factories = $factories; + } + + /** + * {@inheritdoc} + */ + public function createTransport(): TransportInterface { + $dsn = $this->configFactory->get('system.mail')->get('mailer_dsn'); + $dsnObject = new Dsn(...$dsn); + return $this->fromDsnObject($dsnObject); + } + +} diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php new file mode 100644 index 00000000000..8a2b5368db0 --- /dev/null +++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Mailer; + +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * An interface defining mailer transport service factory implementations. + * + * The transport service factory is responsible to create a transport instance + * according to the site configuration. The default service factory looks up the + * `mailer_dsn` key from the `system.mail` config and returns an appropriate + * transport implementation. + * + * Contrib and custom code may choose to replace or decorate the transport + * service factory in order to provide a mailer transport instance which + * requires more complex setup. + */ +interface TransportServiceFactoryInterface { + + /** + * Creates and returns a configured mailer transport class. + */ + public function createTransport(): TransportInterface; + +} diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php new file mode 100644 index 00000000000..c4aa2c736a4 --- /dev/null +++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Mailer; + +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * A trait containing helper methods for transport service construction. + */ +trait TransportServiceFactoryTrait { + + /** + * A list of transport factories. + * + * @var Iterable<TransportFactoryInterface> + */ + protected iterable $factories; + + /** + * Constructs a transport instance given a DSN object. + * + * @param \Symfony\Component\Mailer\Transport\Dsn $dsn + * The mailer DSN object. + * + * @throws \Symfony\Component\Mailer\Exception\IncompleteDsnException + * @throws \Symfony\Component\Mailer\Exception\UnsupportedSchemeException + */ + protected function fromDsnObject(Dsn $dsn): TransportInterface { + foreach ($this->factories as $factory) { + if ($factory->supports($dsn)) { + return $factory->create($dsn); + } + } + + throw new UnsupportedSchemeException($dsn); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php index 31de8a4485c..0cbfa25c850 100644 --- a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php +++ b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php @@ -5,6 +5,7 @@ namespace Drupal\Core\Menu; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\CacheCollector; use Drupal\Core\Lock\LockBackendInterface; +use Drupal\Core\Path\PathMatcherInterface; use Drupal\Core\Routing\RouteMatchInterface; /** @@ -40,11 +41,23 @@ class MenuActiveTrail extends CacheCollector implements MenuActiveTrailInterface * The cache backend. * @param \Drupal\Core\Lock\LockBackendInterface $lock * The lock backend. + * @param \Drupal\Core\Path\PathMatcherInterface|null $pathMatcher + * The path.matcher service. */ - public function __construct(MenuLinkManagerInterface $menu_link_manager, RouteMatchInterface $route_match, CacheBackendInterface $cache, LockBackendInterface $lock) { + public function __construct( + MenuLinkManagerInterface $menu_link_manager, + RouteMatchInterface $route_match, + CacheBackendInterface $cache, + LockBackendInterface $lock, + protected ?PathMatcherInterface $pathMatcher = NULL, + ) { parent::__construct(NULL, $cache, $lock); $this->menuLinkManager = $menu_link_manager; $this->routeMatch = $route_match; + if ($this->pathMatcher === NULL) { + @trigger_error('Calling ' . __METHOD__ . ' without the $pathMatcher argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3523495', E_USER_DEPRECATED); + $this->pathMatcher = \Drupal::service('path.matcher'); + } } /** @@ -129,6 +142,7 @@ class MenuActiveTrail extends CacheCollector implements MenuActiveTrailInterface // The menu links coming from the storage are already sorted by depth, // weight and ID. $found = NULL; + $links = []; $route_name = $this->routeMatch->getRouteName(); // On a default (not custom) 403 page the route name is NULL. On a custom @@ -139,10 +153,20 @@ class MenuActiveTrail extends CacheCollector implements MenuActiveTrailInterface // Load links matching this route. $links = $this->menuLinkManager->loadLinksByRoute($route_name, $route_parameters, $menu_name); - // Select the first matching link. - if ($links) { - $found = reset($links); - } + } + + // If the request is for the site's front page, then menu links containing + // <front> must also be loaded since it's a special route that's an alias + // for the page. + // @todo Combine the two calls to loadLinksByRoute() in + // https://www.drupal.org/project/drupal/issues/3523497 + if ($this->pathMatcher->isFrontPage()) { + $links = array_merge($links, $this->menuLinkManager->loadLinksByRoute('<front>', [], $menu_name)); + } + + // Select the first matching link. + if ($links) { + $found = reset($links); } return $found; } diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php index 8410e5af3f7..9322c3628c6 100644 --- a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php +++ b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php @@ -23,16 +23,16 @@ interface MenuActiveTrailInterface { public function getActiveTrailIds($menu_name); /** - * Fetches a menu link which matches the route name, parameters and menu name. + * Fetches a menu link that matches the currently active route. * * @param string|null $menu_name * (optional) The menu within which to find the active link. If omitted, all * menus will be searched. * * @return \Drupal\Core\Menu\MenuLinkInterface|null - * The menu link for the given route name, parameters and menu, or NULL if - * there is no matching menu link or the current user cannot access the - * current page (i.e. we have a 403 response). + * The menu link for the currently active route, or NULL if there is no + * matching menu link or the current user cannot access the current page + * (i.e. we have a 403 response). */ public function getActiveLink($menu_name = NULL); diff --git a/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php b/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php index f1db3a342af..9feb9bed8da 100644 --- a/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php +++ b/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php @@ -93,11 +93,14 @@ final class ConsoleInputCollector implements InputCollectorInterface { $method = $settings['method']; $arguments = $settings['arguments'] ?? []; - // Most of the input-collecting methods of StyleInterface have a `default` - // parameter. - $arguments += [ - 'default' => $default_value, - ]; + if ($method !== 'askHidden') { + // Most of the input-collecting methods of StyleInterface have a `default` + // parameter. + $arguments += [ + 'default' => $default_value, + ]; + } + // We don't support using Symfony Console's inline validation; instead, // input definitions should define constraints. unset($arguments['validator']); diff --git a/core/lib/Drupal/Core/Render/Element/ComponentElement.php b/core/lib/Drupal/Core/Render/Element/ComponentElement.php index 70e42154cbb..62db902c068 100644 --- a/core/lib/Drupal/Core/Render/Element/ComponentElement.php +++ b/core/lib/Drupal/Core/Render/Element/ComponentElement.php @@ -67,6 +67,14 @@ class ComponentElement extends RenderElementBase { ), $props ); + + // Handle children as slots. + $children = Element::children($element, TRUE); + foreach ($children as $key) { + $element['#slots'][$key] = $element[$key]; + unset($element[$key]); + } + $inline_template = $this->generateComponentTemplate( $element['#component'], $element['#slots'], diff --git a/core/lib/Drupal/Core/Render/Element/StatusReport.php b/core/lib/Drupal/Core/Render/Element/StatusReport.php index 41fc7d7fda1..057e314d24e 100644 --- a/core/lib/Drupal/Core/Render/Element/StatusReport.php +++ b/core/lib/Drupal/Core/Render/Element/StatusReport.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Render\Element; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Render\Attribute\RenderElement; /** @@ -34,21 +35,21 @@ class StatusReport extends RenderElementBase { * This function is assigned as a #pre_render callback. */ public static function preRenderGroupRequirements($element) { - $severities = static::getSeverities(); $grouped_requirements = []; + RequirementSeverity::convertLegacyIntSeveritiesToEnums($element['#requirements'], __METHOD__); + /** @var array{title: \Drupal\Core\StringTranslation\TranslatableMarkup, value: mixed, description: \Drupal\Core\StringTranslation\TranslatableMarkup, severity: \Drupal\Core\Extension\Requirement\RequirementSeverity} $requirement */ foreach ($element['#requirements'] as $key => $requirement) { - $severity = $severities[REQUIREMENT_INFO]; + $severity = RequirementSeverity::Info; if (isset($requirement['severity'])) { - $requirement_severity = (int) $requirement['severity'] === REQUIREMENT_OK ? REQUIREMENT_INFO : (int) $requirement['severity']; - $severity = $severities[$requirement_severity]; + $severity = $requirement['severity'] === RequirementSeverity::OK ? RequirementSeverity::Info : $requirement['severity']; } elseif (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'install') { - $severity = $severities[REQUIREMENT_OK]; + $severity = RequirementSeverity::OK; } - $grouped_requirements[$severity['status']]['title'] = $severity['title']; - $grouped_requirements[$severity['status']]['type'] = $severity['status']; - $grouped_requirements[$severity['status']]['items'][$key] = $requirement; + $grouped_requirements[$severity->status()]['title'] = $severity->title(); + $grouped_requirements[$severity->status()]['type'] = $severity->status(); + $grouped_requirements[$severity->status()]['items'][$key] = $requirement; } // Order the grouped requirements by a set order. @@ -68,22 +69,28 @@ class StatusReport extends RenderElementBase { * @return array * An associative array of the requirements severities. The keys are the * requirement constants defined in install.inc. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3410939 */ public static function getSeverities() { + @trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3410939', \E_USER_DEPRECATED); return [ - REQUIREMENT_INFO => [ + RequirementSeverity::Info->value => [ 'title' => t('Checked', [], ['context' => 'Examined']), 'status' => 'checked', ], - REQUIREMENT_OK => [ + RequirementSeverity::OK->value => [ 'title' => t('OK'), 'status' => 'ok', ], - REQUIREMENT_WARNING => [ + RequirementSeverity::Warning->value => [ 'title' => t('Warnings found'), 'status' => 'warning', ], - REQUIREMENT_ERROR => [ + RequirementSeverity::Error->value => [ 'title' => t('Errors found'), 'status' => 'error', ], diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index 8fb473aa8df..1f26381bb18 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -8,6 +8,7 @@ use Drupal\Component\Utility\Variable; use Drupal\Component\Utility\Xss; use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Form\FormHelper; use Drupal\Core\Render\Element\RenderCallbackInterface; @@ -96,6 +97,9 @@ class Renderer implements RendererInterface { * {@inheritdoc} */ public function renderRoot(&$elements) { + if (!$elements) { + return ''; + } // Disallow calling ::renderRoot() from within another ::renderRoot() call. if ($this->isRenderingRoot) { $this->isRenderingRoot = FALSE; @@ -104,21 +108,39 @@ class Renderer implements RendererInterface { // Render in its own render context. $this->isRenderingRoot = TRUE; - $output = $this->executeInRenderContext(new RenderContext(), function () use (&$elements) { - return $this->render($elements, TRUE); - }); - $this->isRenderingRoot = FALSE; + try { + $output = $this->renderInIsolation($elements); + } + // Since #pre_render, #post_render, #lazy_builder callbacks and theme + // functions or templates may be used for generating a render array's + // content, and we might be rendering the main content for the page, it is + // possible that any of them throw an exception that will cause a different + // page to be rendered (e.g. throwing + // \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause + // the 404 page to be rendered). That page might also use + // Renderer::renderRoot() but if exceptions aren't caught here, it will be + // impossible to call Renderer::renderRoot() again. + // Hence, catch all exceptions, reset the isRenderingRoot property and + // re-throw exceptions. + finally { + $this->isRenderingRoot = FALSE; + } + + if ((string) $output === '') { + return ''; + } - return $output; + return $elements['#markup']; } /** * {@inheritdoc} */ public function renderInIsolation(&$elements) { - return $this->executeInRenderContext(new RenderContext(), function () use (&$elements) { - return $this->render($elements, TRUE); - }); + $context = new RenderContext(); + return Markup::create($this->executeInRenderContext($context, function () use (&$elements, $context) { + return $this->doRenderRoot($elements, $context); + })); } /** @@ -188,31 +210,60 @@ class Renderer implements RendererInterface { * {@inheritdoc} */ public function render(&$elements, $is_root_call = FALSE) { - // Since #pre_render, #post_render, #lazy_builder callbacks and theme - // functions or templates may be used for generating a render array's - // content, and we might be rendering the main content for the page, it is - // possible that any of them throw an exception that will cause a different - // page to be rendered (e.g. throwing - // \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause - // the 404 page to be rendered). That page might also use - // Renderer::renderRoot() but if exceptions aren't caught here, it will be - // impossible to call Renderer::renderRoot() again. - // Hence, catch all exceptions, reset the isRenderingRoot property and - // re-throw exceptions. - try { - return $this->doRender($elements, $is_root_call); + $context = $this->getCurrentRenderContext(); + if (!isset($context)) { + throw new \LogicException("Render context is empty, because render() was called outside of a renderRoot() or renderPlain() call. Use renderPlain()/renderRoot() or #lazy_builder/#pre_render instead."); } - catch (\Exception $e) { - // Mark the ::rootRender() call finished due to this exception & re-throw. - $this->isRenderingRoot = FALSE; - throw $e; + + if ($is_root_call) { + trigger_error(__METHOD__ . ' with $is_root_call is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use ' . __CLASS__ . '::renderRoot() instead. See https://www.drupal.org/node/3497318.'); + return $this->doRenderRoot($elements, $context); + } + + return $this->doRender($elements, $context); + } + + /** + * See the docs for ::render(). + */ + protected function doRenderRoot(array &$elements, RenderContext $context): string|MarkupInterface { + if (!$elements) { + return ''; + } + // Set the bubbleable rendering metadata that has configurable defaults + // to ensure that the final render array definitely has these configurable + // defaults, even when no subtree is render cached. + $required_cache_contexts = $this->rendererConfig['required_cache_contexts']; + + if (isset($elements['#cache']['contexts'])) { + $elements['#cache']['contexts'] = Cache::mergeContexts($elements['#cache']['contexts'], $required_cache_contexts); } + else { + $elements['#cache']['contexts'] = $required_cache_contexts; + } + + // Render the elements normally. + $return = $this->doRender($elements, $context); + + // If there is no output, return early as placeholders can't make a + // difference. + if ((string) $return === '') { + return $return; + } + + // Only when rendering the root do placeholders have to be processed. If we + // were to replace them while rendering cacheable nested elements, their + // cacheable metadata would still bubble all the way up the render tree, + // effectively making the use of placeholders pointless. + $this->replacePlaceholders($elements); + + return $elements['#markup']; } /** * See the docs for ::render(). */ - protected function doRender(&$elements, $is_root_call = FALSE) { + protected function doRender(array &$elements, RenderContext $context): string|MarkupInterface { if (empty($elements)) { return ''; } @@ -233,10 +284,6 @@ class Renderer implements RendererInterface { $this->addCacheableDependency($elements, $elements['#access']); if (!$elements['#access']->isAllowed()) { // Abort, but bubble new cache metadata from the access result. - $context = $this->getCurrentRenderContext(); - if (!isset($context)) { - throw new \LogicException("Render context is empty, because render() was called outside of a renderRoot() or renderInIsolation() call. Use renderInIsolation()/renderRoot() or #lazy_builder/#pre_render instead."); - } $context->push(new BubbleableMetadata()); $context->update($elements); $context->bubble(); @@ -259,12 +306,10 @@ class Renderer implements RendererInterface { } $context->push(new BubbleableMetadata()); - // Set the bubbleable rendering metadata that has configurable defaults, if: - // - this is the root call, to ensure that the final render array definitely - // has these configurable defaults, even when no subtree is render cached. - // - this is a render cacheable subtree, to ensure that the cached data has - // the configurable defaults (which may affect the ID and invalidation). - if ($is_root_call || isset($elements['#cache']['keys'])) { + // Set the bubbleable rendering metadata that has configurable defaults if + // this is a render cacheable subtree, to ensure that the cached data has + // the configurable defaults (which may affect the ID and invalidation). + if (isset($elements['#cache']['keys'])) { $required_cache_contexts = $this->rendererConfig['required_cache_contexts']; if (isset($elements['#cache']['contexts'])) { $elements['#cache']['contexts'] = Cache::mergeContexts($elements['#cache']['contexts'], $required_cache_contexts); @@ -287,12 +332,6 @@ class Renderer implements RendererInterface { $cached_element = $this->renderCache->get($elements); if ($cached_element !== FALSE) { $elements = $cached_element; - // Only when we're in a root (non-recursive) Renderer::render() call, - // placeholders must be processed, to prevent breaking the render cache - // in case of nested elements with #cache set. - if ($is_root_call) { - $this->replacePlaceholders($elements); - } // Mark the element markup as safe if is it a string. if (is_string($elements['#markup'])) { $elements['#markup'] = Markup::create($elements['#markup']); @@ -464,7 +503,7 @@ class Renderer implements RendererInterface { // same process as Renderer::render() but is inlined for speed. if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) { foreach ($children as $key) { - $elements['#children'] .= $this->doRender($elements[$key]); + $elements['#children'] .= $this->doRender($elements[$key], $context); } $elements['#children'] = Markup::create($elements['#children']); } @@ -558,23 +597,6 @@ class Renderer implements RendererInterface { $context->update($elements); } - // Only when we're in a root (non-recursive) Renderer::render() call, - // placeholders must be processed, to prevent breaking the render cache in - // case of nested elements with #cache set. - // - // By running them here, we ensure that: - // - they run when #cache is disabled, - // - they run when #cache is enabled and there is a cache miss. - // Only the case of a cache hit when #cache is enabled, is not handled here, - // that is handled earlier in Renderer::render(). - if ($is_root_call) { - $this->replacePlaceholders($elements); - // @todo remove as part of https://www.drupal.org/node/2511330. - if ($context->count() !== 1) { - throw new \LogicException('A stray RendererInterface::render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.'); - } - } - // Rendering is finished, all necessary info collected! $context->bubble(); @@ -757,6 +779,9 @@ class Renderer implements RendererInterface { * {@inheritdoc} */ public function addCacheableDependency(array &$elements, $dependency) { + if (!$dependency instanceof CacheableDependencyInterface) { + @trigger_error(sprintf("Calling %s() with an object that doesn't implement %s is deprecated in drupal:11.3.0 and will throw an error in drupal:13.0.0. See https://www.drupal.org/node/3525389", __METHOD__, CacheableDependencyInterface::class), E_USER_DEPRECATED); + } $meta_a = CacheableMetadata::createFromRenderArray($elements); $meta_b = CacheableMetadata::createFromObject($dependency); $meta_a->merge($meta_b)->applyTo($elements); diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php index 92fa661f6b0..020e594755f 100644 --- a/core/lib/Drupal/Core/Render/RendererInterface.php +++ b/core/lib/Drupal/Core/Render/RendererInterface.php @@ -323,7 +323,7 @@ interface RendererInterface { * (Internal use only.) Whether this is a recursive call or not. See * ::renderRoot(). * - * @return \Drupal\Component\Render\MarkupInterface + * @return \Drupal\Component\Render\MarkupInterface|string * The rendered HTML. * * @throws \LogicException diff --git a/core/lib/Drupal/Core/Session/SessionManager.php b/core/lib/Drupal/Core/Session/SessionManager.php index 0170626181c..6927ba2ebbe 100644 --- a/core/lib/Drupal/Core/Session/SessionManager.php +++ b/core/lib/Drupal/Core/Session/SessionManager.php @@ -6,6 +6,7 @@ use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Database\Connection; use Drupal\Core\DependencyInjection\DependencySerializationTrait; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\SessionBagInterface; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; /** @@ -162,6 +163,16 @@ class SessionManager extends NativeSessionStorage implements SessionManagerInter parent::save(); } + $allowedKeys = array_map( + fn (SessionBagInterface $bag) => $bag->getStorageKey(), + $this->bags + ); + $allowedKeys[] = $this->getMetadataBag()->getStorageKey(); + $deprecatedKeys = array_diff(array_keys($_SESSION), $allowedKeys); + if (count($deprecatedKeys) > 0) { + @trigger_error(sprintf('Storing values directly in $_SESSION is deprecated in drupal:11.2.0 and will become unsupported in drupal:12.0.0. Use $request->getSession()->set() instead. Affected keys: %s. See https://www.drupal.org/node/3518527', implode(", ", $deprecatedKeys)), E_USER_DEPRECATED); + } + $this->startedLazy = FALSE; } diff --git a/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php b/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php index d141d202ecb..e3669f8f145 100644 --- a/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php +++ b/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php @@ -122,13 +122,8 @@ class ComponentLoader implements LoaderInterface { catch (ComponentNotFoundException) { throw new LoaderError('Unable to find component'); } - // If any of the templates, or the component definition, are fresh. Then the - // component is fresh. $metadata_path = $component->getPluginDefinition()[YamlDirectoryDiscovery::FILE_KEY]; - if ($file_is_fresh($metadata_path)) { - return TRUE; - } - return $file_is_fresh($component->getTemplatePath()); + return $file_is_fresh($component->getTemplatePath()) && $file_is_fresh($metadata_path); } } diff --git a/core/lib/Drupal/Core/Test/PhpUnitTestDiscovery.php b/core/lib/Drupal/Core/Test/PhpUnitTestDiscovery.php new file mode 100644 index 00000000000..d9737028821 --- /dev/null +++ b/core/lib/Drupal/Core/Test/PhpUnitTestDiscovery.php @@ -0,0 +1,346 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Test; + +use Drupal\Core\Test\Exception\MissingGroupException; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\DataProviderTestSuite; +use PHPUnit\Framework\Test; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestSuite; +use PHPUnit\TextUI\Configuration\Builder; +use PHPUnit\TextUI\Configuration\TestSuiteBuilder; + +/** + * Discovers available tests using the PHPUnit API. + * + * @internal + */ +class PhpUnitTestDiscovery { + + /** + * The map of legacy test suite identifiers to phpunit.xml ones. + * + * @var array<string,string> + */ + private array $map = [ + 'PHPUnit-FunctionalJavascript' => 'functional-javascript', + 'PHPUnit-Functional' => 'functional', + 'PHPUnit-Kernel' => 'kernel', + 'PHPUnit-Unit' => 'unit', + 'PHPUnit-Unit-Component' => 'unit-component', + 'PHPUnit-Build' => 'build', + ]; + + /** + * The reverse map of legacy test suite identifiers to phpunit.xml ones. + * + * @var array<string,string> + */ + private array $reverseMap; + + /** + * The warnings generated during the discovery. + * + * @var list<string> + */ + private array $warnings = []; + + public function __construct( + private string $configurationFilePath, + ) { + $this->reverseMap = array_flip($this->map); + } + + /** + * Discovers available tests. + * + * @param string|null $extension + * (optional) The name of an extension to limit discovery to; e.g., 'node'. + * @param list<string> $testSuites + * (optional) An array of PHPUnit test suites to filter the discovery for. + * @param string|null $directory + * (optional) Limit discovered tests to a specific directory. + * + * @return array<string<array<class-string, array{name: class-string, description: string, group: string|int, groups: list<string|int>, type: string, file: string, tests_count: positive-int}>>> + * An array of test groups keyed by the group name. Each test group is an + * array of test class information arrays as returned by + * ::getTestClassInfo(), keyed by test class. If a test class belongs to + * multiple groups, it will appear under all group keys it belongs to. + */ + public function getTestClasses(?string $extension = NULL, array $testSuites = [], ?string $directory = NULL): array { + $this->warnings = []; + + $args = ['--configuration', $this->configurationFilePath]; + + if (!empty($testSuites)) { + // Convert $testSuites from Drupal's legacy syntax to the syntax used in + // phpunit.xml, that is necessary to PHPUnit to be able to apply the + // test suite filter. For example, 'PHPUnit-Unit' to 'unit'. + $tmp = []; + foreach ($testSuites as $i) { + if (!is_string($i)) { + throw new \InvalidArgumentException("Test suite must be a string"); + } + if (str_contains($i, ' ')) { + throw new \InvalidArgumentException("Test suite name '{$i}' is invalid"); + } + $tmp[] = $this->map[$i] ?? $i; + } + $args[] = '--testsuite=' . implode(',', $tmp); + } + + if ($directory !== NULL) { + $args[] = $directory; + } + + $phpUnitConfiguration = (new Builder())->build($args); + + // TestSuiteBuilder calls the test data providers during the discovery. + // Data providers may be changing the Drupal service container, which leads + // to potential issues. We save the current container before running the + // discovery, and in case a change is detected, reset it and raise + // warnings so that developers can tune their data provider code. + if (\Drupal::hasContainer()) { + $container = \Drupal::getContainer(); + $containerObjectId = spl_object_id($container); + } + $phpUnitTestSuite = (new TestSuiteBuilder())->build($phpUnitConfiguration); + if (isset($containerObjectId) && $containerObjectId !== spl_object_id(\Drupal::getContainer())) { + $this->warnings[] = '*** The service container was changed during the test discovery ***'; + $this->warnings[] = 'Probably a test data provider method called \\Drupal::setContainer.'; + $this->warnings[] = 'Ensure that all the data providers restore the original container before returning data.'; + assert(isset($container)); + \Drupal::setContainer($container); + } + + $list = $directory === NULL ? + $this->getTestList($phpUnitTestSuite, $extension) : + $this->getTestListLimitedToDirectory($phpUnitTestSuite, $extension, $testSuites); + + // Sort the groups and tests within the groups by name. + uksort($list, 'strnatcasecmp'); + foreach ($list as &$tests) { + uksort($tests, 'strnatcasecmp'); + } + + return $list; + } + + /** + * Discovers all class files in all available extensions. + * + * @param string|null $extension + * (optional) The name of an extension to limit discovery to; e.g., 'node'. + * @param string|null $directory + * (optional) Limit discovered tests to a specific directory. + * + * @return array + * A classmap containing all discovered class files; i.e., a map of + * fully-qualified classnames to path names. + */ + public function findAllClassFiles(?string $extension = NULL, ?string $directory = NULL): array { + $testClasses = $this->getTestClasses($extension, [], $directory); + $classMap = []; + foreach ($testClasses as $group) { + foreach ($group as $className => $info) { + $classMap[$className] = $info['file']; + } + } + return $classMap; + } + + /** + * Returns the warnings generated during the discovery. + * + * @return list<string> + * The warnings. + */ + public function getWarnings(): array { + return $this->warnings; + } + + /** + * Returns a list of tests from a TestSuite object. + * + * @param \PHPUnit\Framework\TestSuite $phpUnitTestSuite + * The TestSuite object returned by PHPUnit test discovery. + * @param string|null $extension + * The name of an extension to limit discovery to; e.g., 'node'. + * + * @return array<string<array<class-string, array{name: class-string, description: string, group: string|int, groups: list<string|int>, type: string, file: string, tests_count: positive-int}>>> + * An array of test groups keyed by the group name. Each test group is an + * array of test class information arrays as returned by + * ::getTestClassInfo(), keyed by test class. If a test class belongs to + * multiple groups, it will appear under all group keys it belongs to. + */ + private function getTestList(TestSuite $phpUnitTestSuite, ?string $extension): array { + $list = []; + foreach ($phpUnitTestSuite->tests() as $testSuite) { + foreach ($testSuite->tests() as $testClass) { + if ($extension !== NULL && !str_starts_with($testClass->name(), "Drupal\\Tests\\{$extension}\\")) { + continue; + } + + $item = $this->getTestClassInfo( + $testClass, + $this->reverseMap[$testSuite->name()] ?? $testSuite->name(), + ); + + foreach ($item['groups'] as $group) { + $list[$group][$item['name']] = $item; + } + } + } + return $list; + } + + /** + * Returns a list of tests from a TestSuite object limited to a directory. + * + * @param \PHPUnit\Framework\TestSuite $phpUnitTestSuite + * The TestSuite object returned by PHPUnit test discovery. + * @param string|null $extension + * The name of an extension to limit discovery to; e.g., 'node'. + * @param list<string> $testSuites + * An array of PHPUnit test suites to filter the discovery for. + * + * @return array<string<array<class-string, array{name: class-string, description: string, group: string|int, groups: list<string|int>, type: string, file: string, tests_count: positive-int}>>> + * An array of test groups keyed by the group name. Each test group is an + * array of test class information arrays as returned by + * ::getTestClassInfo(), keyed by test class. If a test class belongs to + * multiple groups, it will appear under all group keys it belongs to. + */ + private function getTestListLimitedToDirectory(TestSuite $phpUnitTestSuite, ?string $extension, array $testSuites): array { + $list = []; + + // In this case, PHPUnit found a single test class to run tests for. + if ($phpUnitTestSuite->isForTestClass()) { + if ($extension !== NULL && !str_starts_with($phpUnitTestSuite->name(), "Drupal\\Tests\\{$extension}\\")) { + return []; + } + + // Take the test suite name from the class namespace. + $testSuite = 'PHPUnit-' . TestDiscovery::getPhpunitTestSuite($phpUnitTestSuite->name()); + if (!empty($testSuites) && !in_array($testSuite, $testSuites, TRUE)) { + return []; + } + + $item = $this->getTestClassInfo($phpUnitTestSuite, $testSuite); + + foreach ($item['groups'] as $group) { + $list[$group][$item['name']] = $item; + } + return $list; + } + + // Multiple test classes were found. + $list = []; + foreach ($phpUnitTestSuite->tests() as $testClass) { + if ($extension !== NULL && !str_starts_with($testClass->name(), "Drupal\\Tests\\{$extension}\\")) { + continue; + } + + // Take the test suite name from the class namespace. + $testSuite = 'PHPUnit-' . TestDiscovery::getPhpunitTestSuite($testClass->name()); + if (!empty($testSuites) && !in_array($testSuite, $testSuites, TRUE)) { + continue; + } + + $item = $this->getTestClassInfo($testClass, $testSuite); + + foreach ($item['groups'] as $group) { + $list[$group][$item['name']] = $item; + } + } + return $list; + + } + + /** + * Returns the test class information. + * + * @param \PHPUnit\Framework\Test $testClass + * The test class. + * @param string $testSuite + * The test suite of this test class. + * + * @return array{name: class-string, description: string, group: string|int, groups: list<string|int>, type: string, file: string, tests_count: positive-int} + * The test class information. + */ + private function getTestClassInfo(Test $testClass, string $testSuite): array { + $reflection = new \ReflectionClass($testClass->name()); + + // Let PHPUnit API return the groups, as it will deal transparently with + // annotations or attributes, but skip groups generated by PHPUnit + // internally and starting with a double underscore prefix. + if (RunnerVersion::getMajor() < 11) { + $groups = array_filter($testClass->groups(), function (string $value): bool { + return !str_starts_with($value, '__phpunit'); + }); + } + else { + // In PHPUnit 11+, we need to coalesce the groups from individual tests + // as they may not be available from the test class level (when tests are + // backed by data providers). + $tmp = []; + foreach ($testClass as $test) { + if ($test instanceof DataProviderTestSuite) { + foreach ($test as $testWithData) { + $tmp = array_merge($tmp, $testWithData->groups()); + } + } + else { + $tmp = array_merge($tmp, $test->groups()); + } + } + $groups = array_filter(array_unique($tmp), function (string $value): bool { + return !str_starts_with($value, '__phpunit'); + }); + } + if (empty($groups)) { + throw new MissingGroupException(sprintf('Missing group metadata in test class %s', $testClass->name())); + } + + // Let PHPUnit API return the class coverage information. + $test = $testClass; + while (!$test instanceof TestCase) { + $test = $test->tests()[0]; + } + if (($metadata = $test->valueObjectForEvents()->metadata()->isCoversClass()) && $metadata->isNotEmpty()) { + $description = sprintf('Tests %s.', $metadata->asArray()[0]->className()); + } + elseif (($metadata = $test->valueObjectForEvents()->metadata()->isCoversDefaultClass()) && $metadata->isNotEmpty()) { + $description = sprintf('Tests %s.', $metadata->asArray()[0]->className()); + } + else { + $description = TestDiscovery::parseTestClassSummary($reflection->getDocComment()); + } + + // Find the test cases count. + $count = 0; + foreach ($testClass->tests() as $testCase) { + if ($testCase instanceof TestCase) { + // If it's a straight test method, counts 1. + $count++; + } + else { + // It's a data provider test suite, count 1 per data set provided. + $count += count($testCase->tests()); + } + } + + return [ + 'name' => $testClass->name(), + 'group' => $groups[0], + 'groups' => $groups, + 'type' => $testSuite, + 'description' => $description, + 'file' => $reflection->getFileName(), + 'tests_count' => $count, + ]; + } + +} diff --git a/core/lib/Drupal/Core/Test/RunTests/TestFileParser.php b/core/lib/Drupal/Core/Test/RunTests/TestFileParser.php index 12aa757e57e..8ab5260aa66 100644 --- a/core/lib/Drupal/Core/Test/RunTests/TestFileParser.php +++ b/core/lib/Drupal/Core/Test/RunTests/TestFileParser.php @@ -4,9 +4,16 @@ namespace Drupal\Core\Test\RunTests; use PHPUnit\Framework\TestCase; +@trigger_error('Drupal\Core\Test\RunTests\TestFileParser is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3447698', E_USER_DEPRECATED); + /** * Parses class names from PHP files without loading them. * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3447698 + * * @internal */ class TestFileParser { diff --git a/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php b/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php index cb754e1afaa..0c000d675c3 100644 --- a/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php +++ b/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php @@ -95,13 +95,14 @@ class SimpletestTestRunResultsStorage implements TestRunResultsStorageInterface * {@inheritdoc} */ public function removeResults(TestRun $test_run): int { - $this->connection->startTransaction('delete_test_run'); + $transaction = $this->connection->startTransaction('delete_test_run'); $this->connection->delete('simpletest') ->condition('test_id', $test_run->id()) ->execute(); $count = $this->connection->delete('simpletest_test_id') ->condition('test_id', $test_run->id()) ->execute(); + $transaction->commitOrRelease(); return $count; } @@ -169,9 +170,10 @@ class SimpletestTestRunResultsStorage implements TestRunResultsStorageInterface */ public function cleanUp(): int { // Clear test results. - $this->connection->startTransaction('delete_simpletest'); + $transaction = $this->connection->startTransaction('delete_simpletest'); $this->connection->delete('simpletest')->execute(); $count = $this->connection->delete('simpletest_test_id')->execute(); + $transaction->commitOrRelease(); return $count; } diff --git a/core/lib/Drupal/Core/Test/TestDiscovery.php b/core/lib/Drupal/Core/Test/TestDiscovery.php index 1347d0c583f..468256779b3 100644 --- a/core/lib/Drupal/Core/Test/TestDiscovery.php +++ b/core/lib/Drupal/Core/Test/TestDiscovery.php @@ -7,6 +7,7 @@ use Drupal\Component\Annotation\Reflection\MockFileFinder; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Test\Exception\MissingGroupException; +use PHPUnit\Framework\Attributes\Group; /** * Discovers available tests. @@ -26,6 +27,11 @@ class TestDiscovery { * Statically cached list of test classes. * * @var array + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is + * no replacement. + * + * @see https://www.drupal.org/node/3447698 */ protected $testClasses; @@ -149,8 +155,14 @@ class TestDiscovery { * * @todo Remove singular grouping; retain list of groups in 'group' key. * @see https://www.drupal.org/node/2296615 + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * PhpUnitTestDiscovery::getTestClasses() instead. + * + * @see https://www.drupal.org/node/3447698 */ public function getTestClasses($extension = NULL, array $types = [], ?string $directory = NULL) { + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use PhpUnitTestDiscovery::getTestClasses() instead. See https://www.drupal.org/node/3447698', E_USER_DEPRECATED); if (!isset($extension) && empty($types)) { if (!empty($this->testClasses)) { return $this->testClasses; @@ -175,6 +187,15 @@ class TestDiscovery { catch (MissingGroupException $e) { // If the class name ends in Test and is not a migrate table dump. if (str_ends_with($classname, 'Test') && !str_contains($classname, 'migrate_drupal\Tests\Table')) { + $reflection = new \ReflectionClass($classname); + $groupAttributes = $reflection->getAttributes(Group::class, \ReflectionAttribute::IS_INSTANCEOF); + if (!empty($groupAttributes)) { + $group = '##no-group-annotations'; + $info['group'] = $group; + $info['groups'] = [$group]; + $list[$group][$classname] = $info; + continue; + } throw $e; } // If the class is @group annotation just skip it. Most likely it is an @@ -216,8 +237,14 @@ class TestDiscovery { * @return array * A classmap containing all discovered class files; i.e., a map of * fully-qualified classnames to path names. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * PhpUnitTestDiscovery::findAllClassFiles() instead. + * + * @see https://www.drupal.org/node/3447698 */ public function findAllClassFiles($extension = NULL, ?string $directory = NULL) { + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use PhpUnitTestDiscovery::findAllClassFiles() instead. See https://www.drupal.org/node/3447698', E_USER_DEPRECATED); $classmap = []; $namespaces = $this->registerTestNamespaces(); if (isset($extension)) { @@ -256,8 +283,14 @@ class TestDiscovery { * * @todo Limit to '*Test.php' files (~10% less files to reflect/introspect). * @see https://www.drupal.org/node/2296635 + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is + * no replacement. + * + * @see https://www.drupal.org/node/3447698 */ public static function scanDirectory($namespace_prefix, $path) { + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3447698', E_USER_DEPRECATED); if (!str_ends_with($namespace_prefix, '\\')) { throw new \InvalidArgumentException("Namespace prefix for $path must contain a trailing namespace separator."); } @@ -312,8 +345,14 @@ class TestDiscovery { * * @throws \Drupal\Core\Test\Exception\MissingGroupException * If the class does not have a @group annotation. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is + * no replacement. + * + * @see https://www.drupal.org/node/3447698 */ public static function getTestInfo($classname, $doc_comment = NULL) { + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3447698', E_USER_DEPRECATED); if ($doc_comment === NULL) { $reflection = new \ReflectionClass($classname); $doc_comment = $reflection->getDocComment(); @@ -350,7 +389,7 @@ class TestDiscovery { $info['type'] = 'PHPUnit-' . static::getPhpunitTestSuite($classname); if (!empty($annotations['coversDefaultClass'])) { - $info['description'] = 'Tests ' . $annotations['coversDefaultClass'] . '.'; + $info['description'] = 'Tests ' . ltrim($annotations['coversDefaultClass']) . '.'; } else { $info['description'] = static::parseTestClassSummary($doc_comment); diff --git a/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php b/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php index 246f143d4e2..ff102b5170a 100644 --- a/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php +++ b/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php @@ -199,7 +199,9 @@ class ComponentValidator { $errors = array_filter( $this->validator->getErrors(), function (array $error) use ($context): bool { - if (($error['constraint'] ?? '') !== 'type') { + // Support 5.0 ($error['constraint']) and 6.0 + // ($error['constraint']['name']) at the same time. + if (($error['constraint']['name'] ?? $error['constraint'] ?? '') !== 'type') { return TRUE; } return !Element::isRenderArray($context[$error['property']] ?? NULL); diff --git a/core/lib/Drupal/Core/Theme/ComponentPluginManager.php b/core/lib/Drupal/Core/Theme/ComponentPluginManager.php index a0c93317699..5a3b62773ea 100644 --- a/core/lib/Drupal/Core/Theme/ComponentPluginManager.php +++ b/core/lib/Drupal/Core/Theme/ComponentPluginManager.php @@ -164,6 +164,10 @@ class ComponentPluginManager extends DefaultPluginManager implements Categorizin public function clearCachedDefinitions(): void { parent::clearCachedDefinitions(); $this->componentNegotiator->clearCache(); + // When clearing cached definitions from theme install or uninstall, the + // container is not rebuilt. Unset discovery so it will be re-instantiated + // in getDiscovery() with the updated list of theme directories. + $this->discovery = NULL; } /** diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 24999a781ac..69b32dfa718 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -427,6 +427,8 @@ rowspans rtsp ruleset sameorigin +sandboxed +sandboxing savepoints sayre schemaapi diff --git a/core/misc/drupal.js b/core/misc/drupal.js index 416c4f415a5..641c461a802 100644 --- a/core/misc/drupal.js +++ b/core/misc/drupal.js @@ -404,7 +404,6 @@ window.Drupal = { behaviors: {}, locale: {} }; * * @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js * @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript - * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53 */ Drupal.url.toAbsolute = function (url) { const urlParsingNode = document.createElement('a'); @@ -419,9 +418,7 @@ window.Drupal = { behaviors: {}, locale: {} }; urlParsingNode.setAttribute('href', url); - // IE <= 7 normalizes the URL when assigned to the anchor node similar to - // the other browsers. - return urlParsingNode.cloneNode(false).href; + return urlParsingNode.href; }; /** diff --git a/core/misc/htmx/htmx-assets.js b/core/misc/htmx/htmx-assets.js new file mode 100644 index 00000000000..bc5f00df432 --- /dev/null +++ b/core/misc/htmx/htmx-assets.js @@ -0,0 +1,174 @@ +/** + * @file + * Adds assets the current page requires. + * + * This script fires a custom `htmx:drupal:load` event when the request has + * settled and all script and css files have been successfully loaded on the + * page. + */ + +(function (Drupal, drupalSettings, loadjs, htmx) { + // Disable htmx loading of script tags since we're handling it. + htmx.config.allowScriptTags = false; + + /** + * Used to hold the loadjs promise. + * + * It's declared in htmx:beforeSwap and checked in htmx:afterSettle to trigger + * the custom htmx:drupal:load event. + * + * @type {WeakMap<XMLHttpRequest, Promise>} + */ + const requestAssetsLoaded = new WeakMap(); + + /** + * Helper function to merge two objects recursively. + * + * @param current + * The object to receive the merged values. + * @param sources + * The objects to merge into current. + * + * @return object + * The merged object. + * + * @see https://youmightnotneedjquery.com/#deep_extend + */ + function mergeSettings(current, ...sources) { + if (!current) { + return {}; + } + + sources + .filter((obj) => Boolean(obj)) + .forEach((obj) => { + Object.entries(obj).forEach(([key, value]) => { + switch (Object.prototype.toString.call(value)) { + case '[object Object]': + current[key] = current[key] || {}; + current[key] = mergeSettings(current[key], value); + break; + + case '[object Array]': + current[key] = mergeSettings(new Array(value.length), value); + break; + + default: + current[key] = value; + } + }); + }); + + return current; + } + + /** + * Send the current ajax page state with each request. + * + * @param configRequestEvent + * HTMX event for request configuration. + * + * @see system_js_settings_alter() + * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments + * @see https://htmx.org/api/#on + * @see https://htmx.org/events/#htmx:configRequest + */ + htmx.on('htmx:configRequest', ({ detail }) => { + const url = new URL(detail.path, document.location.href); + if (Drupal.url.isLocal(url.toString())) { + // Allow Drupal to return new JavaScript and CSS files to load without + // returning the ones already loaded. + // @see \Drupal\Core\StackMiddleWare\AjaxPageState + // @see \Drupal\Core\Theme\AjaxBasePageNegotiator + // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset() + // @see system_js_settings_alter() + const pageState = drupalSettings.ajaxPageState; + detail.parameters['ajax_page_state[theme]'] = pageState.theme; + detail.parameters['ajax_page_state[theme_token]'] = pageState.theme_token; + detail.parameters['ajax_page_state[libraries]'] = pageState.libraries; + } + }); + + // @see https://htmx.org/events/#htmx:beforeSwap + htmx.on('htmx:beforeSwap', ({ detail }) => { + // Custom event to detach behaviors. + htmx.trigger(detail.elt, 'htmx:drupal:unload'); + + // We need to parse the response to find all the assets to load. + // htmx cleans up too many things to be able to rely on their dom fragment. + let responseHTML = Document.parseHTMLUnsafe(detail.serverResponse); + + // Update drupalSettings + // Use direct child elements to harden against XSS exploits when CSP is on. + const settingsElement = responseHTML.querySelector( + ':is(head, body) > script[type="application/json"][data-drupal-selector="drupal-settings-json"]', + ); + if (settingsElement !== null) { + mergeSettings(drupalSettings, JSON.parse(settingsElement.textContent)); + } + + // Load all assets files. We sent ajax_page_state in the request so this is only the diff with the current page. + const assetsTags = responseHTML.querySelectorAll( + 'link[rel="stylesheet"][href], script[src]', + ); + const bundleIds = Array.from(assetsTags) + .filter(({ href, src }) => !loadjs.isDefined(href ?? src)) + .map(({ href, src, type, attributes }) => { + const bundleId = href ?? src; + let prefix = 'css!'; + if (src) { + prefix = type === 'module' ? 'module!' : 'js!'; + } + + loadjs(prefix + bundleId, bundleId, { + // JS files are loaded in order, so this needs to be false when 'src' + // is defined. + async: !src, + // Copy asset tag attributes to the new element. + before(path, element) { + // This allows all attributes to be added, like defer, async and + // crossorigin. + Object.values(attributes).forEach((attr) => { + element.setAttribute(attr.name, attr.value); + }); + }, + }); + + return bundleId; + }); + + // Helps with memory management. + responseHTML = null; + + // Nothing to load, we resolve the promise right away. + let assetsLoaded = Promise.resolve(); + // If there are assets to load, use loadjs to manage this process. + if (bundleIds.length) { + // Trigger the event once all the dependencies have loaded. + assetsLoaded = new Promise((resolve, reject) => { + loadjs.ready(bundleIds, { + success: resolve, + error(depsNotFound) { + const message = Drupal.t( + `The following files could not be loaded: @dependencies`, + { '@dependencies': depsNotFound.join(', ') }, + ); + reject(message); + }, + }); + }); + } + + requestAssetsLoaded.set(detail.xhr, assetsLoaded); + }); + + // Trigger the Drupal processing once all assets have been loaded. + // @see https://htmx.org/events/#htmx:afterSettle + htmx.on('htmx:afterSettle', ({ detail }) => { + requestAssetsLoaded.get(detail.xhr).then(() => { + htmx.trigger(detail.elt, 'htmx:drupal:load'); + // This should be automatic but don't wait for the garbage collector. + requestAssetsLoaded.delete(detail.xhr); + }); + }); +})(Drupal, drupalSettings, loadjs, htmx); diff --git a/core/misc/htmx/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js new file mode 100644 index 00000000000..da3ea2eb995 --- /dev/null +++ b/core/misc/htmx/htmx-behaviors.js @@ -0,0 +1,41 @@ +/** + * @file + * Connect Drupal.behaviors to htmx inserted content. + */ +(function (Drupal, htmx, drupalSettings) { + // Flag used to prevent running htmx initialization twice on elements we know + // have already been processed. + let attachFromHtmx = false; + + // This is a custom event that triggers once the htmx request settled and + // all JS and CSS assets have been loaded successfully. + // @see https://htmx.org/api/#on + // @see htmx-assets.js + htmx.on('htmx:drupal:load', ({ detail }) => { + attachFromHtmx = true; + Drupal.attachBehaviors(detail.elt, drupalSettings); + attachFromHtmx = false; + }); + + // When htmx removes elements from the DOM, make sure they're detached first. + // This event is currently an alias of htmx:beforeSwap + htmx.on('htmx:drupal:unload', ({ detail }) => { + Drupal.detachBehaviors(detail.elt, drupalSettings, 'unload'); + }); + + /** + * Initialize HTMX library on content added by Drupal Ajax Framework. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Initialize htmx behavior. + */ + Drupal.behaviors.htmx = { + attach(context) { + if (!attachFromHtmx && context !== document) { + htmx.process(context); + } + }, + }; +})(Drupal, htmx, drupalSettings); diff --git a/core/modules/block/block.module b/core/modules/block/block.module index 94a2cb9fc7a..24e28589491 100644 --- a/core/modules/block/block.module +++ b/core/modules/block/block.module @@ -16,6 +16,10 @@ use Drupal\Core\Installer\InstallerKernel; * @see block_modules_installed() */ function block_themes_installed($theme_list): void { + // Do not create blocks during config sync. + if (\Drupal::service('config.installer')->isSyncing()) { + return; + } // Disable this functionality prior to install profile installation because // block configuration is often optional or provided by the install profile // itself. block_theme_initialize() will be called when the install profile is diff --git a/core/modules/block/migrations/d6_block.yml b/core/modules/block/migrations/d6_block.yml index 74922444e8d..853ce28a47b 100644 --- a/core/modules/block/migrations/d6_block.yml +++ b/core/modules/block/migrations/d6_block.yml @@ -56,8 +56,6 @@ process: 1: forum_new_block locale: 0: language_block - node: - 0: node_syndicate_block search: 0: search_form_block statistics: diff --git a/core/modules/block/migrations/d7_block.yml b/core/modules/block/migrations/d7_block.yml index 9b031b7daa7..35c6f23d86f 100644 --- a/core/modules/block/migrations/d7_block.yml +++ b/core/modules/block/migrations/d7_block.yml @@ -59,8 +59,6 @@ process: new: forum_new_block # locale: # 0: language_block - node: - syndicate: node_syndicate_block search: form: search_form_block statistics: diff --git a/core/modules/block/src/Hook/BlockHooks.php b/core/modules/block/src/Hook/BlockHooks.php index 657109309a3..802a60bccb1 100644 --- a/core/modules/block/src/Hook/BlockHooks.php +++ b/core/modules/block/src/Hook/BlockHooks.php @@ -151,7 +151,12 @@ class BlockHooks { * @see block_themes_installed() */ #[Hook('modules_installed')] - public function modulesInstalled($modules): void { + public function modulesInstalled($modules, bool $is_syncing): void { + // Do not create blocks during config sync. + if ($is_syncing) { + return; + } + // block_themes_installed() does not call block_theme_initialize() during // site installation because block configuration can be optional or provided // by the profile. Now, when the profile is installed, this configuration diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php index 8b2ead48eda..6305ab7f841 100644 --- a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php +++ b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php @@ -65,6 +65,10 @@ class BlockConfigSchemaTest extends KernelTestBase { */ public function testBlockConfigSchema(): void { foreach ($this->blockManager->getDefinitions() as $block_id => $definition) { + // Skip the syndicate block as it is deprecated. + if ($block_id === 'node_syndicate_block') { + continue; + } $id = $this->randomMachineName(); $block = Block::create([ 'id' => $id, diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php new file mode 100644 index 00000000000..80e3f798342 --- /dev/null +++ b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block\Kernel; + +use Drupal\Core\Config\ConfigInstallerInterface; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ThemeInstallerInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\block\Entity\Block; + +/** + * Tests that blocks are not created during config sync. + * + * @group block + */ +class BlockConfigSyncTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'system']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + \Drupal::service(ThemeInstallerInterface::class) + ->install(['stark', 'claro']); + + // Delete all existing blocks. + foreach (Block::loadMultiple() as $block) { + $block->delete(); + } + + // Set the default theme. + $this->config('system.theme') + ->set('default', 'stark') + ->save(); + + // Create a block for the default theme to be copied later. + Block::create([ + 'id' => 'test_block', + 'plugin' => 'system_powered_by_block', + 'region' => 'content', + 'theme' => 'stark', + ])->save(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + $container->setParameter('install_profile', 'testing'); + } + + /** + * Tests blocks are not created during config sync. + * + * @param bool $syncing + * Whether or not config is syncing when the hook is invoked. + * @param string|null $expected_block_id + * The expected ID of the block that should be created, or NULL if no block + * should be created. + * + * @testWith [true, null] + * [false, "claro_test_block"] + */ + public function testNoBlocksCreatedDuringConfigSync(bool $syncing, ?string $expected_block_id): void { + \Drupal::service(ConfigInstallerInterface::class) + ->setSyncing($syncing); + + // Invoke the hook that should skip block creation due to config sync. + \Drupal::moduleHandler()->invoke('block', 'themes_installed', [['claro']]); + // This should hold true if the "current" install profile triggers an + // invocation of hook_modules_installed(). + \Drupal::moduleHandler()->invoke('block', 'modules_installed', [['testing'], $syncing]); + + $this->assertSame($expected_block_id, Block::load('claro_test_block')?->id()); + } + +} diff --git a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php index 3f20b2148b8..dc96d95e699 100644 --- a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php +++ b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php @@ -100,7 +100,7 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { */ public function testBlockMigration(): void { $blocks = Block::loadMultiple(); - $this->assertCount(25, $blocks); + $this->assertCount(24, $blocks); // Check user blocks. $visibility = [ diff --git a/core/modules/block_content/src/Controller/BlockContentController.php b/core/modules/block_content/src/Controller/BlockContentController.php index b2776f51d7d..77f8eee7939 100644 --- a/core/modules/block_content/src/Controller/BlockContentController.php +++ b/core/modules/block_content/src/Controller/BlockContentController.php @@ -2,9 +2,9 @@ namespace Drupal\block_content\Controller; +use Drupal\block_content\BlockContentTypeInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Entity\EntityStorageInterface; -use Drupal\block_content\BlockContentTypeInterface; use Drupal\Core\Extension\ThemeHandlerInterface; use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -88,7 +88,8 @@ class BlockContentController extends ControllerBase { uasort($types, [$this->blockContentTypeStorage->getEntityType()->getClass(), 'sort']); if ($types && count($types) == 1) { $type = reset($types); - return $this->addForm($type, $request); + $query = $request->query->all(); + return $this->redirect('block_content.add_form', ['block_content_type' => $type->id()], ['query' => $query]); } if (count($types) === 0) { return [ diff --git a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php index 844e06895cc..4e6c3b141e7 100644 --- a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php +++ b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php @@ -5,7 +5,6 @@ namespace Drupal\block_content\Plugin\Menu\LocalAction; use Drupal\Core\Menu\LocalActionDefault; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Routing\RouteProviderInterface; -use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -54,11 +53,6 @@ class BlockContentAddLocalAction extends LocalActionDefault { if ($region = $this->requestStack->getCurrentRequest()->query->getString('region')) { $options['query']['region'] = $region; } - - // Adds a destination on content block listing. - if ($route_match->getRouteName() == 'entity.block_content.collection') { - $options['query']['destination'] = Url::fromRoute('<current>')->toString(); - } return $options; } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php index bca42cd3e32..364b5f4524d 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php @@ -155,11 +155,7 @@ class BlockContentCreationTest extends BlockContentTestBase { // Create a block and place in block layout. $this->drupalGet('/admin/content/block'); $this->clickLink('Add content block'); - // Verify destination URL, when clicking "Save and configure" this - // destination will be ignored. - $base = base_path(); - $url = 'block/add?destination=' . $base . 'admin/content/block'; - $this->assertSession()->addressEquals($url); + $this->assertSession()->addressEquals('/block/add/basic'); $edit = []; $edit['info[0][value]'] = 'Test Block'; $edit['body[0][value]'] = $this->randomMachineName(16); diff --git a/core/modules/block_content/tests/src/Functional/LocalActionTest.php b/core/modules/block_content/tests/src/Functional/LocalActionTest.php new file mode 100644 index 00000000000..bb1a20df880 --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/LocalActionTest.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block_content\Functional; + +/** + * Tests block_content local action links. + * + * @group block_content + */ +class LocalActionTest extends BlockContentTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->drupalLogin($this->adminUser); + } + + /** + * Tests the block_content_add_action link. + */ + public function testAddContentBlockLink(): void { + // Verify that the link takes you straight to the block form if there's only + // one type. + $this->drupalGet('/admin/content/block'); + $this->clickLink('Add content block'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('/block/add/basic'); + + $type = $this->randomMachineName(); + $this->createBlockContentType([ + 'id' => $type, + 'label' => $type, + ]); + + // Verify that the link takes you to the block add page if there's more than + // one type. + $this->drupalGet('/admin/content/block'); + $this->clickLink('Add content block'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('/block/add'); + } + +} diff --git a/core/modules/comment/src/Hook/CommentThemeHooks.php b/core/modules/comment/src/Hook/CommentThemeHooks.php index e789af6dab1..c137d586d41 100644 --- a/core/modules/comment/src/Hook/CommentThemeHooks.php +++ b/core/modules/comment/src/Hook/CommentThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\comment\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for comment. @@ -12,7 +12,7 @@ class CommentThemeHooks { /** * Implements hook_preprocess_HOOK() for block templates. */ - #[Preprocess('block')] + #[Hook('preprocess_block')] public function preprocessBlock(&$variables): void { if ($variables['configuration']['provider'] == 'comment') { $variables['attributes']['role'] = 'navigation'; diff --git a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php index db1ffae5a6d..01a40394b40 100644 --- a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php +++ b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\comment_empty_title_test\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for comment_empty_title_test. @@ -14,7 +14,7 @@ class CommentEmptyTitleTestThemeHooks { /** * Implements hook_preprocess_comment(). */ - #[Preprocess('comment')] + #[Hook('preprocess_comment')] public function preprocessComment(&$variables): void { $variables['title'] = ''; } diff --git a/core/modules/comment/tests/src/Functional/CommentAdminTest.php b/core/modules/comment/tests/src/Functional/CommentAdminTest.php index f8dfc8a9b38..69c634ba0f9 100644 --- a/core/modules/comment/tests/src/Functional/CommentAdminTest.php +++ b/core/modules/comment/tests/src/Functional/CommentAdminTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\comment\Functional; use Drupal\comment\CommentInterface; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\user\RoleInterface; @@ -281,8 +280,8 @@ class CommentAdminTest extends CommentTestBase { ]; $this->drupalGet('admin/content/comment'); $this->submitForm($edit, 'Update'); - $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment1->label()])); - $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment2->label()])); + $this->assertSession()->responseContains($comment1->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>"); + $this->assertSession()->responseContains($comment2->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>"); $this->assertSession()->pageTextContains('English'); $this->assertSession()->pageTextContains('Urdu'); $this->submitForm([], 'Delete'); diff --git a/core/modules/comment/tests/src/Functional/CommentPagerTest.php b/core/modules/comment/tests/src/Functional/CommentPagerTest.php index 819403386b1..4927803208b 100644 --- a/core/modules/comment/tests/src/Functional/CommentPagerTest.php +++ b/core/modules/comment/tests/src/Functional/CommentPagerTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\comment\Functional; use Drupal\comment\CommentManagerInterface; -use Drupal\Component\Render\FormattableMarkup; use Drupal\node\Entity\Node; /** @@ -446,7 +445,7 @@ class CommentPagerTest extends CommentTestBase { $url_target = $this->getAbsoluteUrl($urls[$index]->getAttribute('href')); return $this->drupalGet($url_target); } - $this->fail(new FormattableMarkup('Link %label does not exist on @url_before', ['%label' => $xpath, '@url_before' => $url_before])); + $this->fail("Link $xpath does not exist on $url_before"); return FALSE; } diff --git a/core/modules/config/tests/src/Functional/ConfigEntityTest.php b/core/modules/config/tests/src/Functional/ConfigEntityTest.php index 1fe966fb127..d9f9b15724d 100644 --- a/core/modules/config/tests/src/Functional/ConfigEntityTest.php +++ b/core/modules/config/tests/src/Functional/ConfigEntityTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\config\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Uuid\Uuid; use Drupal\Core\Entity\EntityMalformedException; use Drupal\Core\Entity\EntityStorageException; @@ -172,10 +171,9 @@ class ConfigEntityTest extends BrowserTestBase { ]); try { $status = $id_length_config_test->save(); - $this->fail(new FormattableMarkup("config_test entity with ID length @length exceeding the maximum allowed length of @max saved successfully", [ - '@length' => strlen($id_length_config_test->id()), - '@max' => static::MAX_ID_LENGTH, - ])); + $length = strlen($id_length_config_test->id()); + $max = static::MAX_ID_LENGTH; + $this->fail("config_test entity with ID length $length exceeding the maximum allowed length of $max saved successfully"); } catch (ConfigEntityIdLengthException) { // Expected exception; just continue testing. diff --git a/core/modules/config_translation/migrations/d6_block_translation.yml b/core/modules/config_translation/migrations/d6_block_translation.yml index 6d57fdae1be..7925c49626f 100644 --- a/core/modules/config_translation/migrations/d6_block_translation.yml +++ b/core/modules/config_translation/migrations/d6_block_translation.yml @@ -39,8 +39,6 @@ process: 1: forum_new_block locale: 0: language_block - node: - 0: node_syndicate_block search: 0: search_form_block statistics: diff --git a/core/modules/config_translation/migrations/d7_block_translation.yml b/core/modules/config_translation/migrations/d7_block_translation.yml index 9c82ee6b678..d2530e3b50a 100644 --- a/core/modules/config_translation/migrations/d7_block_translation.yml +++ b/core/modules/config_translation/migrations/d7_block_translation.yml @@ -44,8 +44,6 @@ process: new: forum_new_block # locale: # 0: language_block - node: - syndicate: node_syndicate_block search: form: search_form_block statistics: diff --git a/core/modules/contact/src/Hook/ContactFormHooks.php b/core/modules/contact/src/Hook/ContactFormHooks.php index ad8223c3ec6..b31b929bddf 100644 --- a/core/modules/contact/src/Hook/ContactFormHooks.php +++ b/core/modules/contact/src/Hook/ContactFormHooks.php @@ -4,7 +4,7 @@ namespace Drupal\contact\Hook; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Hook\Attribute\FormAlter; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\user\UserDataInterface; @@ -29,7 +29,7 @@ class ContactFormHooks { * * @see \Drupal\user\ProfileForm::form() */ - #[FormAlter('user_form')] + #[Hook('form_user_form_alter')] public function formUserFormAlter(&$form, FormStateInterface $form_state) : void { $form['contact'] = [ '#type' => 'details', @@ -55,7 +55,7 @@ class ContactFormHooks { * * Adds the default personal contact setting on the user settings page. */ - #[FormAlter('user_admin_settings')] + #[Hook('form_user_admin_settings_alter')] public function formUserAdminSettingsAlter(&$form, FormStateInterface $form_state) : void { $form['contact'] = [ '#type' => 'details', diff --git a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php index bac903bdd29..df4f0834788 100644 --- a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php +++ b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\contact\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Test\AssertMailTrait; @@ -106,12 +105,7 @@ class ContactPersonalTest extends BrowserTestBase { $this->drupalLogin($this->adminUser); // Verify that the correct watchdog message has been logged. $this->drupalGet('/admin/reports/dblog'); - $placeholders = [ - '@sender_name' => $this->webUser->getAccountName(), - '@sender_email' => $this->webUser->getEmail(), - '@recipient_name' => $this->contactUser->getAccountName(), - ]; - $this->assertSession()->responseContains(new FormattableMarkup('@sender_name (@sender_email) sent @recipient_name an email.', $placeholders)); + $this->assertSession()->responseContains($this->webUser->getAccountName() . " (" . HTML::escape($this->webUser->getEmail()) . ") sent " . $this->contactUser->getAccountName() . " an email."); // Ensure an unescaped version of the email does not exist anywhere. $this->assertSession()->responseNotContains($this->webUser->getEmail()); diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index bfc1c996c98..6798d02d907 100644 --- a/core/modules/contextual/contextual.libraries.yml +++ b/core/modules/contextual/contextual.libraries.yml @@ -1,16 +1,9 @@ drupal.contextual-links: version: VERSION js: + js/contextualModelView.js: {} # Ensure to run before contextual/drupal.context-toolbar. - # Core. js/contextual.js: { weight: -2 } - # Models. - js/models/StateModel.js: { weight: -2 } - # Views. - js/views/AuralView.js: { weight: -2 } - js/views/KeyboardView.js: { weight: -2 } - js/views/RegionView.js: { weight: -2 } - js/views/VisualView.js: { weight: -2 } css: component: css/contextual.module.css: {} @@ -22,28 +15,21 @@ drupal.contextual-links: - core/drupal - core/drupal.ajax - core/drupalSettings - # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920 - - core/internal.backbone - core/once - core/drupal.touchevents-test drupal.contextual-toolbar: version: VERSION js: + js/toolbar/contextualToolbarModelView.js: {} js/contextual.toolbar.js: {} - # Models. - js/toolbar/models/StateModel.js: {} - # Views. - js/toolbar/views/AuralView.js: {} - js/toolbar/views/VisualView.js: {} css: component: css/contextual.toolbar.css: {} dependencies: - core/jquery + - contextual/drupal.contextual-links - core/drupal - # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920 - - core/internal.backbone - core/once - core/drupal.tabbingmanager - core/drupal.announce diff --git a/core/modules/contextual/css/contextual.theme.css b/core/modules/contextual/css/contextual.theme.css index 06a6728be39..55a83d5ca12 100644 --- a/core/modules/contextual/css/contextual.theme.css +++ b/core/modules/contextual/css/contextual.theme.css @@ -17,6 +17,10 @@ left: 0; } +.contextual.open { + z-index: 501; +} + /** * Contextual region. */ diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 87ccaa52dff..f1008eabe07 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module. */ -(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) { +(function ($, Drupal, drupalSettings, JSON, storage) { const options = $.extend( drupalSettings.contextual, // Merge strings on top of drupalSettings so that they are not mutable. @@ -14,22 +14,19 @@ }, }, ); - // Clear the cached contextual links whenever the current user's set of // permissions changes. const cachedPermissionsHash = storage.getItem( 'Drupal.contextual.permissionsHash', ); - const permissionsHash = drupalSettings.user.permissionsHash; + const { permissionsHash } = drupalSettings.user; if (cachedPermissionsHash !== permissionsHash) { if (typeof permissionsHash === 'string') { - _.chain(storage) - .keys() - .each((key) => { - if (key.startsWith('Drupal.contextual.')) { - storage.removeItem(key); - } - }); + Object.keys(storage).forEach((key) => { + if (key.startsWith('Drupal.contextual.')) { + storage.removeItem(key); + } + }); } storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); } @@ -87,7 +84,7 @@ */ function initContextual($contextual, html) { const $region = $contextual.closest('.contextual-region'); - const contextual = Drupal.contextual; + const { contextual } = Drupal; $contextual // Update the placeholder to contain its rendered contextual links. @@ -107,46 +104,18 @@ const glue = url.includes('?') ? '&' : '?'; this.setAttribute('href', url + glue + destination); }); - let title = ''; const $regionHeading = $region.find('h2'); if ($regionHeading.length) { title = $regionHeading[0].textContent.trim(); } - // Create a model and the appropriate views. - const model = new contextual.StateModel({ - title, - }); - const viewOptions = $.extend({ el: $contextual, model }, options); - contextual.views.push({ - visual: new contextual.VisualView(viewOptions), - aural: new contextual.AuralView(viewOptions), - keyboard: new contextual.KeyboardView(viewOptions), - }); - contextual.regionViews.push( - new contextual.RegionView($.extend({ el: $region, model }, options)), - ); - - // Add the model to the collection. This must happen after the views have - // been associated with it, otherwise collection change event handlers can't - // trigger the model change event handler in its views. - contextual.collection.add(model); - - // Let other JavaScript react to the adding of a new contextual link. - $(document).trigger( - 'drupalContextualLinkAdded', - Drupal.deprecatedProperty({ - target: { - $el: $contextual, - $region, - model, - }, - deprecatedProperty: 'model', - message: - 'The model property is deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no replacement.', - }), + options.title = title; + const contextualModelView = new Drupal.contextual.ContextualModelView( + $contextual, + $region, + options, ); - + contextual.instances.push(contextualModelView); // Fix visual collisions between contextual link triggers. adjustIfNestedAndOverlapping($contextual); } @@ -192,7 +161,7 @@ // Initialize after the current execution cycle, to make the AJAX // request for retrieving the uncached contextual links as soon as // possible, but also to ensure that other Drupal behaviors have had - // the chance to set up an event listener on the Backbone collection + // the chance to set up an event listener on the collection // Drupal.contextual.collection. window.setTimeout(() => { initContextual( @@ -217,7 +186,7 @@ data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens }, dataType: 'json', success(results) { - _.each(results, (html, contextualID) => { + Object.entries(results).forEach(([contextualID, html]) => { // Store the metadata. storage.setItem(`Drupal.contextual.${contextualID}`, html); // If the rendered contextual links are empty, then the current @@ -274,21 +243,23 @@ * replacement. */ regionViews: [], + instances: new Proxy([], { + set: function set(obj, prop, value) { + obj[prop] = value; + window.dispatchEvent(new Event('contextual-instances-added')); + return true; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + window.dispatchEvent(new Event('contextual-instances-removed')); + } + }, + }), + ContextualModelView: {}, }; /** - * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances. - * - * @type {Backbone.Collection} - * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.collection = new Backbone.Collection([], { - model: Drupal.contextual.StateModel, - }); - - /** * A trigger is an interactive element often bound to a click handler. * * @return {string} @@ -311,12 +282,4 @@ $(document).on('drupalContextualLinkAdded', (event, data) => { Drupal.ajax.bindAjaxLinks(data.$el[0]); }); -})( - jQuery, - Drupal, - drupalSettings, - _, - Backbone, - window.JSON, - window.sessionStorage, -); +})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage); diff --git a/core/modules/contextual/js/contextual.toolbar.js b/core/modules/contextual/js/contextual.toolbar.js index 8fc206cc2c3..c94d0df414c 100644 --- a/core/modules/contextual/js/contextual.toolbar.js +++ b/core/modules/contextual/js/contextual.toolbar.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module's edit toolbar tab. */ -(function ($, Drupal, Backbone) { +(function ($, Drupal) { const strings = { tabbingReleased: Drupal.t( 'Tabbing is no longer constrained by the Contextual module.', @@ -21,33 +21,19 @@ * A contextual links DOM element as rendered by the server. */ function initContextualToolbar(context) { - if (!Drupal.contextual || !Drupal.contextual.collection) { + if (!Drupal.contextual || !Drupal.contextual.instances) { return; } - const contextualToolbar = Drupal.contextualToolbar; - contextualToolbar.model = new contextualToolbar.StateModel( - { - // Checks whether localStorage indicates we should start in edit mode - // rather than view mode. - // @see Drupal.contextualToolbar.VisualView.persist - isViewing: - document.querySelector('body .contextual-region') === null || - localStorage.getItem('Drupal.contextualToolbar.isViewing') !== - 'false', - }, - { - contextualCollection: Drupal.contextual.collection, - }, - ); + const { contextualToolbar } = Drupal; const viewOptions = { el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'), - model: contextualToolbar.model, strings, }; - new contextualToolbar.VisualView(viewOptions); - new contextualToolbar.AuralView(viewOptions); + contextualToolbar.model = new Drupal.contextual.ContextualToolbarModelView( + viewOptions, + ); } /** @@ -75,13 +61,10 @@ */ Drupal.contextualToolbar = { /** - * The {@link Drupal.contextualToolbar.StateModel} instance. - * - * @type {?Drupal.contextualToolbar.StateModel} + * The {@link Drupal.contextual.ContextualToolbarModelView} instance. * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is - * no replacement. + * @type {?Drupal.contextual.ContextualToolbarModelView} */ model: null, }; -})(jQuery, Drupal, Backbone); +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/contextualModelView.js b/core/modules/contextual/js/contextualModelView.js new file mode 100644 index 00000000000..4488045e223 --- /dev/null +++ b/core/modules/contextual/js/contextualModelView.js @@ -0,0 +1,254 @@ +(($, Drupal) => { + /** + * Models the state of a contextual link's trigger, list & region. + */ + Drupal.contextual.ContextualModelView = class { + constructor($contextual, $region, options) { + this.title = options.title || ''; + this.regionIsHovered = false; + this._hasFocus = false; + this._isOpen = false; + this._isLocked = false; + this.strings = options.strings; + this.timer = NaN; + this.modelId = btoa(Math.random()).substring(0, 12); + this.$region = $region; + this.$contextual = $contextual; + + if (!document.body.classList.contains('touchevents')) { + this.$region.on({ + mouseenter: () => { + this.regionIsHovered = true; + }, + mouseleave: () => { + this.close().blur(); + this.regionIsHovered = false; + }, + 'mouseleave mouseenter': () => this.render(), + }); + this.$contextual.on('mouseenter', () => { + this.focus(); + this.render(); + }); + } + + this.$contextual.on( + { + click: () => { + this.toggleOpen(); + }, + touchend: () => { + Drupal.contextual.ContextualModelView.touchEndToClick(); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.blur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.trigger', + ); + + this.$contextual.on( + { + click: () => { + this.close().blur(); + }, + touchend: (event) => { + Drupal.contextual.ContextualModelView.touchEndToClick(event); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.waitCloseThenBlur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.contextual-links a', + ); + + this.render(); + + // Let other JavaScript react to the adding of a new contextual link. + $(document).trigger('drupalContextualLinkAdded', { + $el: $contextual, + $region, + model: this, + }); + } + + /** + * Updates the rendered representation of the current contextual links. + */ + render() { + const { isOpen } = this; + const isVisible = this.isLocked || this.regionIsHovered || isOpen; + this.$region.toggleClass('focus', this.hasFocus); + this.$contextual + .toggleClass('open', isOpen) + // Update the visibility of the trigger. + .find('.trigger') + .toggleClass('visually-hidden', !isVisible); + + this.$contextual.find('.contextual-links').prop('hidden', !isOpen); + const trigger = this.$contextual.find('.trigger').get(0); + trigger.textContent = Drupal.t('@action @title configuration options', { + '@action': !isOpen ? this.strings.open : this.strings.close, + '@title': this.title, + }); + trigger.setAttribute('aria-pressed', isOpen); + } + + /** + * Prevents delay and simulated mouse events. + * + * @param {jQuery.Event} event the touch end event. + */ + static touchEndToClick(event) { + event.preventDefault(); + event.target.click(); + } + + /** + * Set up a timeout to allow a user to tab between the trigger and the + * contextual links without the menu dismissing. + */ + waitCloseThenBlur() { + this.timer = window.setTimeout(() => { + this.isOpen = false; + this.hasFocus = false; + this.render(); + }, 150); + } + + /** + * Opens or closes the contextual link. + * + * If it is opened, then also give focus. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + toggleOpen() { + const newIsOpen = !this.isOpen; + this.isOpen = newIsOpen; + if (newIsOpen) { + this.focus(); + } + return this; + } + + /** + * Gives focus to this contextual link. + * + * Also closes + removes focus from every other contextual link. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + focus() { + const { modelId } = this; + Drupal.contextual.instances.forEach((model) => { + if (model.modelId !== modelId) { + model.close().blur(); + } + }); + window.clearTimeout(this.timer); + this.hasFocus = true; + return this; + } + + /** + * Removes focus from this contextual link, unless it is open. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + blur() { + if (!this.isOpen) { + this.hasFocus = false; + } + return this; + } + + /** + * Closes this contextual link. + * + * Does not call blur() because we want to allow a contextual link to have + * focus, yet be closed for example when hovering. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + close() { + this.isOpen = false; + return this; + } + + /** + * Gets the current focus state. + * + * @return {boolean} the focus state. + */ + get hasFocus() { + return this._hasFocus; + } + + /** + * Sets the current focus state. + * + * @param {boolean} value - new focus state + */ + set hasFocus(value) { + this._hasFocus = value; + this.$region.toggleClass('focus', this._hasFocus); + } + + /** + * Gets the current open state. + * + * @return {boolean} the open state. + */ + get isOpen() { + return this._isOpen; + } + + /** + * Sets the current open state. + * + * @param {boolean} value - new open state + */ + set isOpen(value) { + this._isOpen = value; + // Nested contextual region handling: hide any nested contextual triggers. + this.$region + .closest('.contextual-region') + .find('.contextual .trigger:not(:first)') + .toggle(!this.isOpen); + } + + /** + * Gets the current locked state. + * + * @return {boolean} the locked state. + */ + get isLocked() { + return this._isLocked; + } + + /** + * Sets the current locked state. + * + * @param {boolean} value - new locked state + */ + set isLocked(value) { + if (value !== this._isLocked) { + this._isLocked = value; + this.render(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js deleted file mode 100644 index 622f897917f..00000000000 --- a/core/modules/contextual/js/models/StateModel.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @file - * A Backbone Model for the state of a contextual link's trigger, list & region. - */ - -(function (Drupal, Backbone) { - /** - * Models the state of a contextual link's trigger, list & region. - * - * @constructor - * - * @augments Backbone.Model - * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.StateModel = Backbone.Model.extend( - /** @lends Drupal.contextual.StateModel# */ { - /** - * @type {object} - * - * @prop {string} title - * @prop {boolean} regionIsHovered - * @prop {boolean} hasFocus - * @prop {boolean} isOpen - * @prop {boolean} isLocked - */ - defaults: /** @lends Drupal.contextual.StateModel# */ { - /** - * The title of the entity to which these contextual links apply. - * - * @type {string} - */ - title: '', - - /** - * Represents if the contextual region is being hovered. - * - * @type {boolean} - */ - regionIsHovered: false, - - /** - * Represents if the contextual trigger or options have focus. - * - * @type {boolean} - */ - hasFocus: false, - - /** - * Represents if the contextual options for an entity are available to - * be selected (i.e. whether the list of options is visible). - * - * @type {boolean} - */ - isOpen: false, - - /** - * When the model is locked, the trigger remains active. - * - * @type {boolean} - */ - isLocked: false, - }, - - /** - * Opens or closes the contextual link. - * - * If it is opened, then also give focus. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - toggleOpen() { - const newIsOpen = !this.get('isOpen'); - this.set('isOpen', newIsOpen); - if (newIsOpen) { - this.focus(); - } - return this; - }, - - /** - * Closes this contextual link. - * - * Does not call blur() because we want to allow a contextual link to have - * focus, yet be closed for example when hovering. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - close() { - this.set('isOpen', false); - return this; - }, - - /** - * Gives focus to this contextual link. - * - * Also closes + removes focus from every other contextual link. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - focus() { - this.set('hasFocus', true); - const cid = this.cid; - this.collection.each((model) => { - if (model.cid !== cid) { - model.close().blur(); - } - }); - return this; - }, - - /** - * Removes focus from this contextual link, unless it is open. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - blur() { - if (!this.get('isOpen')) { - this.set('hasFocus', false); - } - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/contextualToolbarModelView.js b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js new file mode 100644 index 00000000000..6c6db5fe70c --- /dev/null +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js @@ -0,0 +1,175 @@ +(($, Drupal) => { + Drupal.contextual.ContextualToolbarModelView = class { + constructor(options) { + this.strings = options.strings; + this.isVisible = false; + this._contextualCount = Drupal.contextual.instances.count; + this.tabbingContext = null; + this._isViewing = + localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false'; + this.$el = options.el; + + window.addEventListener('contextual-instances-added', () => + this.lockNewContextualLinks(), + ); + window.addEventListener('contextual-instances-removed', () => { + this.contextualCount = Drupal.contextual.instances.count; + }); + + this.$el.on({ + click: () => { + this.isViewing = !this.isViewing; + }, + touchend: (event) => { + event.preventDefault(); + event.target.click(); + }, + 'click touchend': () => this.render(), + }); + + $(document).on('keyup', (event) => this.onKeypress(event)); + this.manageTabbing(true); + this.render(); + } + + /** + * Responds to esc and tab key press events. + * + * @param {jQuery.Event} event + * The keypress event. + */ + onKeypress(event) { + // The first tab key press is tracked so that an announcement about + // tabbing constraints can be raised if edit mode is enabled when the page + // is loaded. + if (!this.announcedOnce && event.keyCode === 9 && !this.isViewing) { + this.announceTabbingConstraint(); + // Set announce to true so that this conditional block won't run again. + this.announcedOnce = true; + } + // Respond to the ESC key. Exit out of edit mode. + if (event.keyCode === 27) { + this.isViewing = true; + } + } + + /** + * Updates the rendered representation of the current toolbar model view. + */ + render() { + this.$el[0].classList.toggle('hidden', this.isVisible); + const button = this.$el[0].querySelector('button'); + button.classList.toggle('is-active', !this.isViewing); + button.setAttribute('aria-pressed', !this.isViewing); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Automatically updates visibility of the view/edit mode toggle. + */ + updateVisibility() { + this.isVisible = this.get('contextualCount') > 0; + } + + /** + * Lock newly added contextual links if edit mode is enabled. + */ + lockNewContextualLinks() { + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Limits tabbing to the contextual links and edit mode toolbar tab. + * + * @param {boolean} init - true to initialize tabbing. + */ + manageTabbing(init = false) { + let { tabbingContext } = this; + // Always release an existing tabbing context. + if (tabbingContext && !init) { + // Only announce release when the context was active. + if (tabbingContext.active) { + Drupal.announce(this.strings.tabbingReleased); + } + tabbingContext.release(); + this.tabbingContext = null; + } + // Create a new tabbing context when edit mode is enabled. + if (!this.isViewing) { + tabbingContext = Drupal.tabbingManager.constrain( + $('.contextual-toolbar-tab, .contextual'), + ); + this.tabbingContext = tabbingContext; + this.announceTabbingConstraint(); + this.announcedOnce = true; + } + } + + /** + * Announces the current tabbing constraint. + */ + announceTabbingConstraint() { + const { strings } = this; + Drupal.announce( + Drupal.formatString(strings.tabbingConstrained, { + '@contextualsCount': Drupal.formatPlural( + Drupal.contextual.instances.length, + '@count contextual link', + '@count contextual links', + ), + }) + strings.pressEsc, + ); + } + + /** + * Gets the current viewing state. + * + * @return {boolean} the viewing state. + */ + get isViewing() { + return this._isViewing; + } + + /** + * Sets the current viewing state. + * + * @param {boolean} value - new viewing state + */ + set isViewing(value) { + this._isViewing = value; + localStorage[!value ? 'setItem' : 'removeItem']( + 'Drupal.contextualToolbar.isViewing', + 'false', + ); + + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.manageTabbing(); + } + + /** + * Gets the current contextual links count. + * + * @return {integer} the current contextual links count. + */ + get contextualCount() { + return this._contextualCount; + } + + /** + * Sets the current contextual links count. + * + * @param {integer} value - new contextual links count. + */ + set contextualCount(value) { + if (value !== this._contextualCount) { + this._contextualCount = value; + this.updateVisibility(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/toolbar/models/StateModel.js b/core/modules/contextual/js/toolbar/models/StateModel.js deleted file mode 100644 index 88f66193f9f..00000000000 --- a/core/modules/contextual/js/toolbar/models/StateModel.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @file - * A Backbone Model for the state of Contextual module's edit toolbar tab. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.StateModel = Backbone.Model.extend( - /** @lends Drupal.contextualToolbar.StateModel# */ { - /** - * @type {object} - * - * @prop {boolean} isViewing - * @prop {boolean} isVisible - * @prop {number} contextualCount - * @prop {Drupal~TabbingContext} tabbingContext - */ - defaults: /** @lends Drupal.contextualToolbar.StateModel# */ { - /** - * Indicates whether the toggle is currently in "view" or "edit" mode. - * - * @type {boolean} - */ - isViewing: true, - - /** - * Indicates whether the toggle should be visible or hidden. Automatically - * calculated, depends on contextualCount. - * - * @type {boolean} - */ - isVisible: false, - - /** - * Tracks how many contextual links exist on the page. - * - * @type {number} - */ - contextualCount: 0, - - /** - * A TabbingContext object as returned by {@link Drupal~TabbingManager}: - * the set of tabbable elements when edit mode is enabled. - * - * @type {?Drupal~TabbingContext} - */ - tabbingContext: null, - }, - - /** - * Models the state of the edit mode toggle. - * - * @constructs - * - * @augments Backbone.Model - * - * @param {object} attrs - * Attributes for the backbone model. - * @param {object} options - * An object with the following option: - * @param {Backbone.collection} options.contextualCollection - * The collection of {@link Drupal.contextual.StateModel} models that - * represent the contextual links on the page. - */ - initialize(attrs, options) { - // Respond to new/removed contextual links. - this.listenTo( - options.contextualCollection, - 'reset remove add', - this.countContextualLinks, - ); - this.listenTo( - options.contextualCollection, - 'add', - this.lockNewContextualLinks, - ); - - // Automatically determine visibility. - this.listenTo(this, 'change:contextualCount', this.updateVisibility); - - // Whenever edit mode is toggled, lock all contextual links. - this.listenTo(this, 'change:isViewing', (model, isViewing) => { - options.contextualCollection.each((contextualModel) => { - contextualModel.set('isLocked', !isViewing); - }); - }); - }, - - /** - * Tracks the number of contextual link models in the collection. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added or removed. - * @param {Backbone.Collection} contextualCollection - * The collection of contextual link models. - */ - countContextualLinks(contextualModel, contextualCollection) { - this.set('contextualCount', contextualCollection.length); - }, - - /** - * Lock newly added contextual links if edit mode is enabled. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added. - * @param {Backbone.Collection} [contextualCollection] - * The collection of contextual link models. - */ - lockNewContextualLinks(contextualModel, contextualCollection) { - if (!this.get('isViewing')) { - contextualModel.set('isLocked', true); - } - }, - - /** - * Automatically updates visibility of the view/edit mode toggle. - */ - updateVisibility() { - this.set('isVisible', this.get('contextualCount') > 0); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/views/AuralView.js b/core/modules/contextual/js/toolbar/views/AuralView.js deleted file mode 100644 index 2bcf9cdcca0..00000000000 --- a/core/modules/contextual/js/toolbar/views/AuralView.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @file - * A Backbone View that provides the aural view of the edit mode toggle. - */ - -(function ($, Drupal, Backbone, _) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.AuralView = Backbone.View.extend( - /** @lends Drupal.contextualToolbar.AuralView# */ { - /** - * Tracks whether the tabbing constraint announcement has been read once. - * - * @type {boolean} - */ - announcedOnce: false, - - /** - * Renders the aural view of the edit mode toggle (screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize(options) { - this.options = options; - - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.model, 'change:isViewing', this.manageTabbing); - - $(document).on('keyup', _.bind(this.onKeypress, this)); - this.manageTabbing(); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextualToolbar.AuralView} - * The current contextual toolbar aural view. - */ - render() { - // Render the state. - this.$el - .find('button') - .attr('aria-pressed', !this.model.get('isViewing')); - - return this; - }, - - /** - * Limits tabbing to the contextual links and edit mode toolbar tab. - */ - manageTabbing() { - let tabbingContext = this.model.get('tabbingContext'); - // Always release an existing tabbing context. - if (tabbingContext) { - // Only announce release when the context was active. - if (tabbingContext.active) { - Drupal.announce(this.options.strings.tabbingReleased); - } - tabbingContext.release(); - } - // Create a new tabbing context when edit mode is enabled. - if (!this.model.get('isViewing')) { - tabbingContext = Drupal.tabbingManager.constrain( - $('.contextual-toolbar-tab, .contextual'), - ); - this.model.set('tabbingContext', tabbingContext); - this.announceTabbingConstraint(); - this.announcedOnce = true; - } - }, - - /** - * Announces the current tabbing constraint. - */ - announceTabbingConstraint() { - const strings = this.options.strings; - Drupal.announce( - Drupal.formatString(strings.tabbingConstrained, { - '@contextualsCount': Drupal.formatPlural( - Drupal.contextual.collection.length, - '@count contextual link', - '@count contextual links', - ), - }), - ); - Drupal.announce(strings.pressEsc); - }, - - /** - * Responds to esc and tab key press events. - * - * @param {jQuery.Event} event - * The keypress event. - */ - onKeypress(event) { - // The first tab key press is tracked so that an announcement about - // tabbing constraints can be raised if edit mode is enabled when the page - // is loaded. - if ( - !this.announcedOnce && - event.keyCode === 9 && - !this.model.get('isViewing') - ) { - this.announceTabbingConstraint(); - // Set announce to true so that this conditional block won't run again. - this.announcedOnce = true; - } - // Respond to the ESC key. Exit out of edit mode. - if (event.keyCode === 27) { - this.model.set('isViewing', true); - } - }, - }, - ); -})(jQuery, Drupal, Backbone, _); diff --git a/core/modules/contextual/js/toolbar/views/VisualView.js b/core/modules/contextual/js/toolbar/views/VisualView.js deleted file mode 100644 index 10d8dff2dea..00000000000 --- a/core/modules/contextual/js/toolbar/views/VisualView.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @file - * A Backbone View that provides the visual view of the edit mode toggle. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.VisualView = Backbone.View.extend( - /** @lends Drupal.contextualToolbar.VisualView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Prevents delay and simulated mouse events. - const touchEndToClick = function (event) { - event.preventDefault(); - event.target.click(); - }; - - return { - click() { - this.model.set('isViewing', !this.model.get('isViewing')); - }, - touchend: touchEndToClick, - }; - }, - - /** - * Renders the visual view of the edit mode toggle. - * - * Listens to mouse & touch and handles edit mode toggle interactions. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.model, 'change:isViewing', this.persist); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextualToolbar.VisualView} - * The current contextual toolbar visual view. - */ - render() { - // Render the visibility. - this.$el.toggleClass('hidden', !this.model.get('isVisible')); - // Render the state. - this.$el - .find('button') - .toggleClass('is-active', !this.model.get('isViewing')); - - return this; - }, - - /** - * Model change handler; persists the isViewing value to localStorage. - * - * `isViewing === true` is the default, so only stores in localStorage when - * it's not the default value (i.e. false). - * - * @param {Drupal.contextualToolbar.StateModel} model - * A {@link Drupal.contextualToolbar.StateModel} model. - * @param {boolean} isViewing - * The value of the isViewing attribute in the model. - */ - persist(model, isViewing) { - if (!isViewing) { - localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false'); - } else { - localStorage.removeItem('Drupal.contextualToolbar.isViewing'); - } - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js deleted file mode 100644 index 62287c1bf11..00000000000 --- a/core/modules/contextual/js/views/AuralView.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @file - * A Backbone View that provides the aural view of a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.AuralView = Backbone.View.extend( - /** @lends Drupal.contextual.AuralView# */ { - /** - * Renders the aural view of a contextual link (i.e. screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize(options) { - this.options = options; - - this.listenTo(this.model, 'change', this.render); - - // Initial render. - this.render(); - }, - - /** - * {@inheritdoc} - */ - render() { - const isOpen = this.model.get('isOpen'); - - // Set the hidden property of the links. - this.$el.find('.contextual-links').prop('hidden', !isOpen); - - // Update the view of the trigger. - const $trigger = this.$el.find('.trigger'); - $trigger - .each((index, element) => { - element.textContent = Drupal.t( - '@action @title configuration options', - { - '@action': !isOpen - ? this.options.strings.open - : this.options.strings.close, - '@title': this.model.get('title'), - }, - ); - }) - .attr('aria-pressed', isOpen); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js deleted file mode 100644 index 2a3d144bea0..00000000000 --- a/core/modules/contextual/js/views/KeyboardView.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file - * A Backbone View that provides keyboard interaction for a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.KeyboardView = Backbone.View.extend( - /** @lends Drupal.contextual.KeyboardView# */ { - /** - * @type {object} - */ - events: { - 'focus .trigger': 'focus', - 'focus .contextual-links a': 'focus', - 'blur .trigger': function () { - this.model.blur(); - }, - 'blur .contextual-links a': function () { - // Set up a timeout to allow a user to tab between the trigger and the - // contextual links without the menu dismissing. - const that = this; - this.timer = window.setTimeout(() => { - that.model.close().blur(); - }, 150); - }, - }, - - /** - * Provides keyboard interaction for a contextual link. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - /** - * The timer is used to create a delay before dismissing the contextual - * links on blur. This is only necessary when keyboard users tab into - * contextual links without edit mode (i.e. without TabbingManager). - * That means that if we decide to disable tabbing of contextual links - * without edit mode, all this timer logic can go away. - * - * @type {NaN|number} - */ - this.timer = NaN; - }, - - /** - * Sets focus on the model; Clears the timer that dismisses the links. - */ - focus() { - // Clear the timeout that might have been set by blurring a link. - window.clearTimeout(this.timer); - this.model.focus(); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js deleted file mode 100644 index 349428301d8..00000000000 --- a/core/modules/contextual/js/views/RegionView.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file - * A Backbone View that renders the visual view of a contextual region element. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.RegionView = Backbone.View.extend( - /** @lends Drupal.contextual.RegionView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Used for tracking the presence of touch events. When true, the - // mousemove and mouseenter event handlers are effectively disabled. - // This is used instead of preventDefault() on touchstart as some - // touchstart events are not cancelable. - let touchStart = false; - return { - touchstart() { - // Set to true so the mouseenter and mouseleave events that follow - // know to not execute any hover related logic. - touchStart = true; - }, - mouseenter() { - if (!touchStart) { - this.model.set('regionIsHovered', true); - } - }, - mouseleave() { - if (!touchStart) { - this.model.close().blur().set('regionIsHovered', false); - } - }, - mousemove() { - // Because there are scenarios where there are both touchscreens - // and pointer devices, the touchStart flag should be set back to - // false after mouseenter and mouseleave complete. It will be set to - // true if another touchstart event occurs. - touchStart = false; - }, - }; - }, - - /** - * Renders the visual view of a contextual region element. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change:hasFocus', this.render); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextual.RegionView} - * The current contextual region view. - */ - render() { - this.$el.toggleClass('focus', this.model.get('hasFocus')); - - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js deleted file mode 100644 index fcd932b1faf..00000000000 --- a/core/modules/contextual/js/views/VisualView.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @file - * A Backbone View that provides the visual view of a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.VisualView = Backbone.View.extend( - /** @lends Drupal.contextual.VisualView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Prevents delay and simulated mouse events. - const touchEndToClick = function (event) { - event.preventDefault(); - event.target.click(); - }; - - // Used for tracking the presence of touch events. When true, the - // mousemove and mouseenter event handlers are effectively disabled. - // This is used instead of preventDefault() on touchstart as some - // touchstart events are not cancelable. - let touchStart = false; - - return { - touchstart() { - // Set to true so the mouseenter events that follows knows to not - // execute any hover related logic. - touchStart = true; - }, - mouseenter() { - // We only want mouse hover events on non-touch. - if (!touchStart) { - this.model.focus(); - } - }, - mousemove() { - // Because there are scenarios where there are both touchscreens - // and pointer devices, the touchStart flag should be set back to - // false after mouseenter and mouseleave complete. It will be set to - // true if another touchstart event occurs. - touchStart = false; - }, - 'click .trigger': function () { - this.model.toggleOpen(); - }, - 'touchend .trigger': touchEndToClick, - 'click .contextual-links a': function () { - this.model.close().blur(); - }, - 'touchend .contextual-links a': touchEndToClick, - }; - }, - - /** - * Renders the visual view of a contextual link. Listens to mouse & touch. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change', this.render); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextual.VisualView} - * The current contextual visual view. - */ - render() { - const isOpen = this.model.get('isOpen'); - // The trigger should be visible when: - // - the mouse hovered over the region, - // - the trigger is locked, - // - and for as long as the contextual menu is open. - const isVisible = - this.model.get('isLocked') || - this.model.get('regionIsHovered') || - isOpen; - - this.$el - // The open state determines if the links are visible. - .toggleClass('open', isOpen) - // Update the visibility of the trigger. - .find('.trigger') - .toggleClass('visually-hidden', !isVisible); - - // Nested contextual region handling: hide any nested contextual triggers. - if ('isOpen' in this.model.changed) { - this.$el - .closest('.contextual-region') - .find('.contextual .trigger:not(:first)') - .toggle(!isOpen); - } - - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/src/Hook/ContextualThemeHooks.php b/core/modules/contextual/src/Hook/ContextualThemeHooks.php index 760a42c9785..7d873196b43 100644 --- a/core/modules/contextual/src/Hook/ContextualThemeHooks.php +++ b/core/modules/contextual/src/Hook/ContextualThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\contextual\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; /** @@ -21,7 +21,7 @@ class ContextualThemeHooks { * @see contextual_page_attachments() * @see \Drupal\contextual\ContextualController::render() */ - #[Preprocess] + #[Hook('preprocess')] public function preprocess(&$variables, $hook, $info): void { // Determine the primary theme function argument. if (!empty($info['variables'])) { diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php index 75e56b5f76b..1d4fa243c49 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php @@ -73,47 +73,40 @@ class EditModeTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Get the page twice to ensure edit mode remains enabled after a new page // request. - for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) { - $this->drupalGet('user'); - $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); - - // After the page loaded we need to additionally wait until the settings - // tray Ajax activity is done. - if ($page_get_count === 0) { - $web_assert->assertWaitOnAjaxRequest(); - } - - if ($page_get_count == 0) { - $unrestricted_tab_count = $this->getTabbableElementsCount(); - $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); - - // Enable edit mode. - // After the first page load the page will be in edit mode when loaded. - $this->pressToolbarEditButton(); - } - - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Disable edit mode. - $this->pressToolbarEditButton(); - $this->assertAnnounceLeaveEditMode(); - $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); - // Enable edit mode again. - $this->pressToolbarEditButton(); - // Finally assert that the 'edit mode enabled' announcement is still - // correct after toggling the edit mode at least once. - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Test while Edit Mode is enabled it doesn't interfere with pages with - // no contextual links. - $this->drupalGet('admin/structure/block'); - $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); - $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); - $this->assertGreaterThan(0, $this->getTabbableElementsCount()); - } - + $this->drupalGet('user'); + $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); + + // After the page loaded we need to additionally wait until the settings + // tray Ajax activity is done. + $web_assert->assertWaitOnAjaxRequest(); + + $unrestricted_tab_count = $this->getTabbableElementsCount(); + $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); + + // Enable edit mode. + // After the first page load the page will be in edit mode when loaded. + $this->pressToolbarEditButton(); + + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Disable edit mode. + $this->pressToolbarEditButton(); + $this->assertAnnounceLeaveEditMode(); + $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); + // Enable edit mode again. + $this->pressToolbarEditButton(); + // Finally assert that the 'edit mode enabled' announcement is still + // correct after toggling the edit mode at least once. + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Test while Edit Mode is enabled it doesn't interfere with pages with + // no contextual links. + $this->drupalGet('admin/structure/block'); + $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); + $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); + $this->assertGreaterThan(0, $this->getTabbableElementsCount()); } /** diff --git a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php index f2c2578f320..d6dee40b55e 100644 --- a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php +++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\datetime\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; @@ -190,11 +189,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffSince( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['past_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); // Verify that the 'datetime_time_ago' formatter works for intervals in // the future. First update the test entity so that the date difference @@ -211,11 +214,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffUntil( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['future_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); } } @@ -341,11 +348,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffSince( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['past_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); // Verify that the 'datetime_time_ago' formatter works for intervals in the // future. First update the test entity so that the date difference always @@ -363,11 +374,15 @@ class DateTimeFieldTest extends DateTestBase { ->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffUntil( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['future_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); // Test the required field validation error message. $entity = EntityTest::create(['name' => 'test datetime required message']); @@ -375,9 +390,9 @@ class DateTimeFieldTest extends DateTestBase { $form_state = new FormState(); \Drupal::formBuilder()->submitForm($form, $form_state); $errors = $form_state->getErrors(); - $expected_error_message = new FormattableMarkup('The %field date is required.', ['%field' => $field_label]); + $expected_error_message = "The <em class=\"placeholder\">$field_label</em> date is required."; $actual_error_message = $errors["{$field_name}][0][value"]->__toString(); - $this->assertEquals($expected_error_message->__toString(), $actual_error_message); + $this->assertEquals($expected_error_message, $actual_error_message); } /** diff --git a/core/modules/dblog/tests/src/Functional/DbLogTest.php b/core/modules/dblog/tests/src/Functional/DbLogTest.php index 95c46392443..d1a09aed265 100644 --- a/core/modules/dblog/tests/src/Functional/DbLogTest.php +++ b/core/modules/dblog/tests/src/Functional/DbLogTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\dblog\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Database; use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Link; use Drupal\Core\Url; use Drupal\dblog\Controller\DbLogController; -use Drupal\error_test\Controller\ErrorTestController; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\system\Functional\Menu\AssertBreadcrumbTrait; @@ -914,16 +912,9 @@ class DbLogTest extends BrowserTestBase { $wid = $query->execute()->fetchField(); $this->drupalGet('admin/reports/dblog/event/' . $wid); - $error_user_notice = [ - '%type' => 'User warning', - '@message' => 'Drupal & awesome', - '%function' => ErrorTestController::class . '->generateWarnings()', - '%file' => $this->getModulePath('error_test') . '/error_test.module', - ]; - // Check if the full message displays on the details page and backtrace is a // pre-formatted text. - $message = new FormattableMarkup('%type: @message in %function (line', $error_user_notice); + $message = '<em class="placeholder">User warning</em>: Drupal & awesome in <em class="placeholder">Drupal\error_test\Controller\ErrorTestController->generateWarnings()</em> (line'; $this->assertSession()->responseContains($message); $this->assertSession()->responseContains('<pre class="backtrace">'); } diff --git a/core/modules/editor/tests/src/Functional/EditorAdminTest.php b/core/modules/editor/tests/src/Functional/EditorAdminTest.php index 639aa030618..12ec751f41c 100644 --- a/core/modules/editor/tests/src/Functional/EditorAdminTest.php +++ b/core/modules/editor/tests/src/Functional/EditorAdminTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\editor\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\filter\Entity\FilterFormat; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; @@ -142,7 +142,7 @@ class EditorAdminTest extends BrowserTestBase { $this->drupalLogin($account); // The node edit page header. - $text = (string) new FormattableMarkup('<em>Edit @type</em> @title', ['@type' => $node_type->label(), '@title' => $node->label()]); + $text = sprintf('<em>Edit %s</em> %s', Html::escape($node_type->label()), Html::escape($node->label())); // Go to node edit form. $this->drupalGet('node/' . $node->id() . '/edit'); diff --git a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php index de14164bd80..48a5c652c8c 100644 --- a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php +++ b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\Functional\FunctionalString; -use Drupal\Component\Render\FormattableMarkup; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; @@ -95,7 +94,7 @@ class StringFieldTest extends BrowserTestBase { $this->drupalGet('entity_test/add'); $this->assertSession()->fieldValueEquals("{$field_name}[0][value]", ''); $this->assertSession()->fieldNotExists("{$field_name}[0][format]"); - $this->assertSession()->responseContains(new FormattableMarkup('placeholder="A placeholder on @widget_type"', ['@widget_type' => $widget_type])); + $this->assertSession()->responseContains('placeholder="A placeholder on ' . $widget_type . '"'); // Submit with some value. $value = $this->randomMachineName(); diff --git a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php index 129f28576d6..76907277eae 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\FunctionalJavascript\EntityReference; use Behat\Mink\Element\NodeElement; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; @@ -317,10 +316,9 @@ class EntityReferenceAdminTest extends WebDriverTestBase { // Try to select the views handler. $this->drupalGet($bundle_path . '/fields/' . $field_name); $page->findField('settings[handler]')->setValue('views'); - $views_text = (string) new FormattableMarkup('No eligible views were found. <a href=":create">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href=":existing">existing view</a>.', [ - ':create' => Url::fromRoute('views_ui.add')->toString(), - ':existing' => Url::fromRoute('entity.view.collection')->toString(), - ]); + $create = Url::fromRoute('views_ui.add')->toString(); + $existing = Url::fromRoute('entity.view.collection')->toString(); + $views_text = 'No eligible views were found. <a href="' . $create . '">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href="' . $existing . '">existing view</a>.'; $assert_session->waitForElement('xpath', '//a[contains(text(), "Create a view")]'); $assert_session->responseContains($views_text); diff --git a/core/modules/file/tests/src/Functional/SaveUploadTest.php b/core/modules/file/tests/src/Functional/SaveUploadTest.php index 66cfe08cad3..14509ea426c 100644 --- a/core/modules/file/tests/src/Functional/SaveUploadTest.php +++ b/core/modules/file/tests/src/Functional/SaveUploadTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\file\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\Core\File\FileExists; use Drupal\Core\Url; use Drupal\file\Entity\File; @@ -738,8 +738,8 @@ class SaveUploadTest extends FileManagedTestBase { $content = (string) $response->getBody(); $this->htmlOutput($content); - $error_text = new FormattableMarkup('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $filename]); - $this->assertStringContainsString((string) $error_text, $content); + $error_text = 'The file <em class="placeholder">' . Html::escape($filename) . '</em> could not be uploaded because the name is invalid.'; + $this->assertStringContainsString($error_text, $content); $this->assertStringContainsString('Epic upload FAIL!', $content); $this->assertFileDoesNotExist('temporary://' . $filename); } diff --git a/core/modules/help/src/HelpTopicTwigLoader.php b/core/modules/help/src/HelpTopicTwigLoader.php index fc2e61bbaaf..9178166597c 100644 --- a/core/modules/help/src/HelpTopicTwigLoader.php +++ b/core/modules/help/src/HelpTopicTwigLoader.php @@ -96,7 +96,7 @@ class HelpTopicTwigLoader extends FilesystemLoader { /** * {@inheritdoc} */ - protected function findTemplate($name, $throw = TRUE) { + protected function findTemplate($name, $throw = TRUE): ?string { if (!str_ends_with($name, '.html.twig')) { if (!$throw) { return NULL; diff --git a/core/modules/help/src/HelpTwigExtension.php b/core/modules/help/src/HelpTwigExtension.php index e41ad66503d..b8a77a914f6 100644 --- a/core/modules/help/src/HelpTwigExtension.php +++ b/core/modules/help/src/HelpTwigExtension.php @@ -41,7 +41,7 @@ class HelpTwigExtension extends AbstractExtension { /** * {@inheritdoc} */ - public function getFunctions() { + public function getFunctions(): array { return [ new TwigFunction('help_route_link', [$this, 'getRouteLink']), new TwigFunction('help_topic_link', [$this, 'getTopicLink']), diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php index f54e15e882a..abe16ebdb48 100644 --- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php +++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php @@ -14,7 +14,7 @@ class HelpTestTwigExtension extends AbstractExtension { /** * {@inheritdoc} */ - public function getNodeVisitors() { + public function getNodeVisitors(): array { return [ new HelpTestTwigNodeVisitor(), ]; diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php index 953f2aa2ce4..9c53a2e0cf3 100644 --- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php +++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php @@ -97,7 +97,7 @@ class HelpTestTwigNodeVisitor implements NodeVisitorInterface { /** * {@inheritdoc} */ - public function getPriority() { + public function getPriority(): int { return -100; } diff --git a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php index 1e182076608..13e6bdffda1 100644 --- a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php +++ b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php @@ -6,8 +6,8 @@ namespace Drupal\Tests\help\Unit; use Drupal\Core\Cache\Cache; use Drupal\help\HelpTopicTwig; -use Drupal\Tests\Core\Template\StubTwigTemplate; use Drupal\Tests\UnitTestCase; +use Twig\Template; use Twig\TemplateWrapper; /** @@ -101,8 +101,8 @@ class HelpTopicTwigTest extends UnitTestCase { ->getMock(); $template = $this - ->getMockBuilder(StubTwigTemplate::class) - ->onlyMethods(['render']) + ->getMockBuilder(Template::class) + ->onlyMethods(['render', 'getTemplateName', 'getDebugInfo', 'getSourceContext', 'doDisplay']) ->setConstructorArgs([$twig]) ->getMock(); diff --git a/core/modules/image/config/install/image.style.large.yml b/core/modules/image/config/install/image.style.large.yml index e0b8394552e..1e327eea8e5 100644 --- a/core/modules/image/config/install/image.style.large.yml +++ b/core/modules/image/config/install/image.style.large.yml @@ -14,7 +14,7 @@ effects: upscale: false 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8: uuid: 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.medium.yml b/core/modules/image/config/install/image.style.medium.yml index f096610c659..d7ea09a6789 100644 --- a/core/modules/image/config/install/image.style.medium.yml +++ b/core/modules/image/config/install/image.style.medium.yml @@ -14,7 +14,7 @@ effects: upscale: false c410ed2f-aa30-4d9c-a224-d2865d9188cd: uuid: c410ed2f-aa30-4d9c-a224-d2865d9188cd - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.thumbnail.yml b/core/modules/image/config/install/image.style.thumbnail.yml index c03c60e00e2..c2d7a4e5042 100644 --- a/core/modules/image/config/install/image.style.thumbnail.yml +++ b/core/modules/image/config/install/image.style.thumbnail.yml @@ -14,7 +14,7 @@ effects: upscale: false c4eb9942-2c9e-4a81-949f-6161a44b6559: uuid: c4eb9942-2c9e-4a81-949f-6161a44b6559 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.wide.yml b/core/modules/image/config/install/image.style.wide.yml index 8573ae26346..b62e05f3e38 100644 --- a/core/modules/image/config/install/image.style.wide.yml +++ b/core/modules/image/config/install/image.style.wide.yml @@ -14,7 +14,7 @@ effects: upscale: false 294c5f76-42a4-43ce-82c2-81c2f4723da0: uuid: 294c5f76-42a4-43ce-82c2-81c2f4723da0 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/schema/image.schema.yml b/core/modules/image/config/schema/image.schema.yml index f805caa378c..68edccf507a 100644 --- a/core/modules/image/config/schema/image.schema.yml +++ b/core/modules/image/config/schema/image.schema.yml @@ -52,6 +52,10 @@ image.effect.image_convert: Choice: callback: 'Drupal\Core\ImageToolkit\ImageToolkitManager::getAllValidExtensions' +image.effect.image_convert_avif: + type: image.effect.image_convert + label: 'Convert to AVIF' + image.effect.image_resize: type: image_size label: 'Image resize' diff --git a/core/modules/image/src/Hook/ImageRequirements.php b/core/modules/image/src/Hook/ImageRequirements.php index cf631bfe375..e1018cf539b 100644 --- a/core/modules/image/src/Hook/ImageRequirements.php +++ b/core/modules/image/src/Hook/ImageRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\image\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\ImageToolkit\ImageToolkitManager; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,7 +47,7 @@ class ImageRequirements { 'title' => $this->t('Image toolkit'), 'value' => $this->t('None'), 'description' => $this->t("No image toolkit is configured on the site. Check PHP installed extensions or add a contributed toolkit that doesn't require a PHP extension. Make sure that at least one valid image toolkit is installed."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } diff --git a/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php new file mode 100644 index 00000000000..595743eece7 --- /dev/null +++ b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\image\Plugin\ImageEffect; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Image\ImageInterface; +use Drupal\Core\ImageToolkit\ImageToolkitManager; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\image\Attribute\ImageEffect; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Converts an image resource to AVIF, with fallback. + */ +#[ImageEffect( + id: "image_convert_avif", + label: new TranslatableMarkup("Convert to AVIF"), + description: new TranslatableMarkup("Converts an image to AVIF, with a fallback if AVIF is not supported."), +)] +class AvifImageEffect extends ConvertImageEffect { + + /** + * The image toolkit manager. + * + * @var \Drupal\Core\ImageToolkit\ImageToolkitManager + */ + protected ImageToolkitManager $imageToolkitManager; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $instance->imageToolkitManager = $container->get(ImageToolkitManager::class); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function applyEffect(ImageInterface $image) { + // If avif is not supported fallback to the parent. + if (!$this->isAvifSupported()) { + return parent::applyEffect($image); + } + + if (!$image->convert('avif')) { + $this->logger->error('Image convert failed using the %toolkit toolkit on %path (%mimetype)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType()]); + return FALSE; + } + + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getDerivativeExtension($extension) { + return $this->isAvifSupported() ? 'avif' : $this->configuration['extension']; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + unset($form['extension']['#options']['avif']); + $form['extension']['#title'] = $this->t('Fallback format'); + $form['extension']['#description'] = $this->t('Format to use if AVIF is not available.'); + return $form; + } + + /** + * Is AVIF supported by the image toolkit. + */ + protected function isAvifSupported(): bool { + return in_array('avif', $this->imageToolkitManager->getDefaultToolkit()->getSupportedExtensions()); + } + +} diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php index 1e5c7533922..54130e7818b 100644 --- a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php +++ b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php @@ -120,6 +120,31 @@ class ImageEffectsTest extends KernelTestBase { } /** + * Tests the 'image_convert_avif' effect when avif is supported. + */ + public function testConvertAvifEffect(): void { + $this->container->get('keyvalue')->get('image_test')->set('avif_enabled', TRUE); + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('avif', $calls['convert'][0][0]); + } + + /** + * Tests the 'image_convert_avif' effect with webp fallback. + */ + public function testConvertAvifEffectFallback(): void { + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('webp', $calls['convert'][0][0]); + } + + /** * Tests the 'image_scale_and_crop' effect. */ public function testScaleAndCropEffect(): void { diff --git a/core/modules/jsonapi/src/Hook/JsonapiRequirements.php b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php index 5cc0225e183..4903389fddf 100644 --- a/core/modules/jsonapi/src/Hook/JsonapiRequirements.php +++ b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\jsonapi\Hook; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -40,7 +41,7 @@ class JsonapiRequirements { $requirements['jsonapi_multilingual_support'] = [ 'title' => $this->t('JSON:API multilingual support'), 'value' => $this->t('Limited'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Some multilingual features currently do not work well with JSON:API. See the <a href=":jsonapi-docs">JSON:API multilingual support documentation</a> for more information on the current status of multilingual support.', [ ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/translations', ]), @@ -49,7 +50,7 @@ class JsonapiRequirements { $requirements['jsonapi_revision_support'] = [ 'title' => $this->t('JSON:API revision support'), 'value' => $this->t('Limited'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Revision support is currently read-only and only for the "Content" and "Media" entity types in JSON:API. See the <a href=":jsonapi-docs">JSON:API revision support documentation</a> for more information on the current status of revision support.', [ ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/revisions', ]), @@ -57,7 +58,7 @@ class JsonapiRequirements { $requirements['jsonapi_read_only_mode'] = [ 'title' => $this->t('JSON:API allowed operations'), 'value' => $this->t('Read-only'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; if (!$this->configFactory->get('jsonapi.settings')->get('read_only')) { $requirements['jsonapi_read_only_mode']['value'] = $this->t('All (create, read, update, delete)'); diff --git a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php index 54fdf4b0d21..fe5ff0ca062 100644 --- a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php +++ b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php @@ -10,6 +10,7 @@ use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase; /** * Tests the migration of language negotiation. * + * @group #slow * @group migrate_drupal_7 */ class MigrateLanguageNegotiationSettingsTest extends MigrateDrupal7TestBase { diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php index 4067edd2616..dc44888a8b2 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php @@ -7,7 +7,6 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContentType; -use Drupal\Component\Render\FormattableMarkup; use Drupal\FunctionalJavascriptTests\JSWebAssert; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; @@ -190,7 +189,7 @@ class LayoutBuilderDisableInteractionsTest extends WebDriverTestBase { try { $element->click(); $tag_name = $element->getTagName(); - $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name])); + $this->fail("$tag_name was clickable when it shouldn't have been"); } catch (\Exception $e) { $this->assertTrue(JSWebAssert::isExceptionNotClickable($e)); diff --git a/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php index d6b8ef7fc24..fd056c38354 100644 --- a/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php +++ b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\layout_discovery\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the layout_discovery module. @@ -19,7 +20,7 @@ class LayoutDiscoveryRequirements implements InstallRequirementsInterface { if (\Drupal::moduleHandler()->moduleExists('layout_plugin')) { $requirements['layout_discovery'] = [ 'description' => t('Layout Discovery cannot be installed because the Layout Plugin module is installed and incompatible.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } return $requirements; diff --git a/core/modules/link/tests/src/Functional/LinkFieldUITest.php b/core/modules/link/tests/src/Functional/LinkFieldUITest.php index 694fb6b3677..5c78abc2391 100644 --- a/core/modules/link/tests/src/Functional/LinkFieldUITest.php +++ b/core/modules/link/tests/src/Functional/LinkFieldUITest.php @@ -15,6 +15,7 @@ use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; * Tests link field UI functionality. * * @group link + * @group #slow */ class LinkFieldUITest extends BrowserTestBase { diff --git a/core/modules/locale/src/Hook/LocaleRequirements.php b/core/modules/locale/src/Hook/LocaleRequirements.php index 6664a64d42b..988c5fcbdd3 100644 --- a/core/modules/locale/src/Hook/LocaleRequirements.php +++ b/core/modules/locale/src/Hook/LocaleRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\locale\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Link; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,7 +47,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => Link::fromTextAndUrl($this->t('Updates available'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => $this->t('Updates available for: @languages. See the <a href=":updates">Available translation updates</a> page for more information.', ['@languages' => implode(', ', $available_updates), ':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } @@ -54,7 +55,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => $this->t('Missing translations'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Missing translations for: @languages. See the <a href=":updates">Available translation updates</a> page for more information.', ['@languages' => implode(', ', $untranslated), ':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } @@ -63,7 +64,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => $this->t('Up to date'), - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, ]; } } @@ -71,7 +72,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => Link::fromTextAndUrl($this->t('Can not determine status'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => $this->t('No translation status is available. See the <a href=":updates">Available translation updates</a> page for more information.', [':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } diff --git a/core/modules/locale/src/Hook/LocaleThemeHooks.php b/core/modules/locale/src/Hook/LocaleThemeHooks.php index d1e438f50ac..4ef5ca0b498 100644 --- a/core/modules/locale/src/Hook/LocaleThemeHooks.php +++ b/core/modules/locale/src/Hook/LocaleThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\locale\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Language\LanguageManagerInterface; @@ -18,7 +18,7 @@ class LocaleThemeHooks { /** * Implements hook_preprocess_HOOK() for node templates. */ - #[Preprocess('node')] + #[Hook('preprocess_node')] public function preprocessNode(&$variables): void { /** @var \Drupal\node\NodeInterface $node */ $node = $variables['node']; diff --git a/core/modules/locale/src/PoDatabaseWriter.php b/core/modules/locale/src/PoDatabaseWriter.php index 7e7340cf107..436d710d7ba 100644 --- a/core/modules/locale/src/PoDatabaseWriter.php +++ b/core/modules/locale/src/PoDatabaseWriter.php @@ -198,10 +198,15 @@ class PoDatabaseWriter implements PoWriterInterface { * {@inheritdoc} */ public function writeItems(PoReaderInterface $reader, $count = -1) { + // Processing multiple writes in a transaction is quicker than committing + // each individual write. + $transaction = \Drupal::database()->startTransaction(); $forever = $count == -1; while (($count-- > 0 || $forever) && ($item = $reader->readItem())) { $this->writeItem($item); } + // Commit the transaction. + unset($transaction); } /** diff --git a/core/modules/mailer/mailer.info.yml b/core/modules/mailer/mailer.info.yml new file mode 100644 index 00000000000..40c9afb713a --- /dev/null +++ b/core/modules/mailer/mailer.info.yml @@ -0,0 +1,6 @@ +name: Mailer +type: module +description: 'Provides an experimental API to build and deliver email messages.' +package: Core (Experimental) +lifecycle: experimental +version: VERSION diff --git a/core/modules/mailer/mailer.services.yml b/core/modules/mailer/mailer.services.yml new file mode 100644 index 00000000000..d69c04a8461 --- /dev/null +++ b/core/modules/mailer/mailer.services.yml @@ -0,0 +1,48 @@ +services: + _defaults: + autoconfigure: true + Symfony\Component\Mailer\Transport\AbstractTransportFactory: + abstract: true + arguments: + - '@Psr\EventDispatcher\EventDispatcherInterface' + - '@?Symfony\Contracts\HttpClient\HttpClientInterface' + # No logger injected on purpose. Log messages generated by transports are + # of little practical use and can lead to errors when a transport instance + # is destructed at the end of a request. + # See: https://www.drupal.org/i/3420372 + - null + public: false + Symfony\Component\Mailer\Transport\NativeTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Symfony\Component\Mailer\Transport\NullTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Symfony\Component\Mailer\Transport\SendmailTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory: + decorates: Symfony\Component\Mailer\Transport\SendmailTransportFactory + autowire: true + public: false + Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory, priority: -100 } + Drupal\Core\Mailer\TransportServiceFactory: + autowire: true + public: false + Drupal\Core\Mailer\TransportServiceFactoryInterface: '@Drupal\Core\Mailer\TransportServiceFactory' + Symfony\Component\Mailer\Transport\TransportInterface: + factory: ['@Drupal\Core\Mailer\TransportServiceFactoryInterface', 'createTransport'] + Symfony\Component\Mailer\Messenger\MessageHandler: + autowire: true + public: false + tags: + - { name: messenger.message_handler } + Symfony\Component\Mailer\Mailer: + autowire: true + Symfony\Component\Mailer\MailerInterface: '@Symfony\Component\Mailer\Mailer' diff --git a/core/modules/mailer/src/Hook/MailerHooks.php b/core/modules/mailer/src/Hook/MailerHooks.php new file mode 100644 index 00000000000..6e1b22e3380 --- /dev/null +++ b/core/modules/mailer/src/Hook/MailerHooks.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\mailer\Hook; + +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Hook implementations for mailer. + */ +class MailerHooks { + + use StringTranslationTrait; + + /** + * Implements hook_help(). + */ + #[Hook('help')] + public function help($route_name, RouteMatchInterface $route_match) : ?string { + switch ($route_name) { + case 'help.page.mailer': + $output = ''; + $output .= '<h3>' . $this->t('About') . '</h3>'; + $output .= '<p>' . $this->t('The Mailer module provides an experimental API to build and deliver email messages based on Symfony mailer component. For more information, see the <a href=":mailer">online documentation for the Mailer module</a>.', [ + ':mailer' => 'https://www.drupal.org/docs/core-modules-and-themes/experimental-extensions/experimental-modules/mailer', + ]) . '</p>'; + return $output; + + default: + return NULL; + } + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml new file mode 100644 index 00000000000..731d5b9bfee --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml @@ -0,0 +1,5 @@ +name: 'Mailer transport factory functional test' +type: module +description: 'Support module for mailer transport factory functional testing.' +package: Testing +version: VERSION diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml new file mode 100644 index 00000000000..8294939c42f --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml @@ -0,0 +1,6 @@ +mailer_transport_factory_functional_test.transport_info: + path: '/mailer-transport-factory-functional-test/transport-info' + defaults: + _controller: '\Drupal\mailer_transport_factory_functional_test\Controller\TransportInfoController::transportInfo' + requirements: + _access: 'TRUE' diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php new file mode 100644 index 00000000000..6f26f95ee81 --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_functional_test\Controller; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * Returns responses for transport info routes. + */ +class TransportInfoController implements ContainerInjectionInterface { + + /** + * Constructs a new transport info controller. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + * @param \Symfony\Component\Mailer\Transport\TransportInterface $transport + * The mailer transport. + */ + public function __construct( + protected ConfigFactoryInterface $configFactory, + protected TransportInterface $transport, + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get(ConfigFactoryInterface::class), + $container->get(TransportInterface::class) + ); + } + + /** + * Returns info about the configured mailer dsn and the resulting transport. + */ + public function transportInfo(): Response { + $mailerDsn = $this->configFactory->get('system.mail')->get('mailer_dsn'); + return new JsonResponse([ + 'mailerDsn' => $mailerDsn, + 'mailerTransportClass' => $this->transport::class, + ]); + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml new file mode 100644 index 00000000000..cbe2e01e9b4 --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml @@ -0,0 +1,5 @@ +name: 'Mailer transport factory kernel test' +type: module +description: 'Support module for mailer transport factory kernel testing.' +package: Testing +version: VERSION diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml new file mode 100644 index 00000000000..1d9dec1cd6b --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autoconfigure: true + Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php new file mode 100644 index 00000000000..a13c57e140a --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_kernel_test\Transport; + +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * A transport only used to test the transport factory adapter. + */ +class CanaryTransport extends AbstractTransport implements TransportInterface { + + protected function doSend(SentMessage $message): void { + } + + /** + * {@inheritdoc} + */ + public function __toString(): string { + return 'drupal.test-canary://default'; + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php new file mode 100644 index 00000000000..4ffc33dfe8a --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_kernel_test\Transport; + +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * A transport factory only used to test the transport factory adapter. + */ +class CanaryTransportFactory extends AbstractTransportFactory implements TransportFactoryInterface { + + protected function getSupportedSchemes(): array { + return ['drupal.test-canary']; + } + + /** + * {@inheritdoc} + */ + public function create(Dsn $dsn): TransportInterface { + if ($dsn->getScheme() === 'drupal.test-canary') { + return new CanaryTransport($this->dispatcher, $this->logger); + } + + throw new UnsupportedSchemeException($dsn, 'test_canary', $this->getSupportedSchemes()); + } + +} diff --git a/core/modules/mailer/tests/src/Functional/GenericTest.php b/core/modules/mailer/tests/src/Functional/GenericTest.php new file mode 100644 index 00000000000..e6c24144c70 --- /dev/null +++ b/core/modules/mailer/tests/src/Functional/GenericTest.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Functional; + +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for mailer. + * + * @group mailer + */ +class GenericTest extends GenericModuleTestBase {} diff --git a/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php new file mode 100644 index 00000000000..318b60829db --- /dev/null +++ b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Functional; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\Mailer\Transport\NullTransport; + +/** + * Tests the transport service factory in the child site of browser tests. + * + * @group mailer + */ +class TransportServiceFactoryTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'mailer', + 'mailer_transport_factory_functional_test', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Test that the transport is set to null://null by default in the child site. + * + * The mailer configuration is set to a safe default during test setUp by + * FunctionalTestSetupTrait::initConfig(). This is in order to prevent tests + * from accidentally sending out emails. This test ensures that the transport + * service is configured correctly in the test child site. + */ + public function testDefaultTestMailFactory(): void { + $response = $this->drupalGet('mailer-transport-factory-functional-test/transport-info'); + $actual = json_decode($response, TRUE); + + $expected = [ + 'mailerDsn' => [ + 'scheme' => 'null', + 'host' => 'null', + 'user' => NULL, + 'password' => NULL, + 'port' => NULL, + 'options' => [], + ], + 'mailerTransportClass' => NullTransport::class, + ]; + $this->assertEquals($expected, $actual); + } + +} diff --git a/core/modules/mailer/tests/src/Kernel/TransportTest.php b/core/modules/mailer/tests/src/Kernel/TransportTest.php new file mode 100644 index 00000000000..f686fe86cc3 --- /dev/null +++ b/core/modules/mailer/tests/src/Kernel/TransportTest.php @@ -0,0 +1,160 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Kernel; + +use Drupal\Core\Site\Settings; +use Drupal\KernelTests\KernelTestBase; +use Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransport; +use PHPUnit\Framework\Attributes\After; +use Symfony\Component\Mailer\Transport\NullTransport; +use Symfony\Component\Mailer\Transport\SendmailTransport; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * Tests the transport factory service. + * + * @group mailer + * @coversDefaultClass \Drupal\Core\Mailer\TransportServiceFactory + */ +class TransportTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['mailer', 'system']; + + /** + * Sets up a mailer DSN config override. + * + * @param string $scheme + * The mailer DSN scheme. + * @param string $host + * The mailer DSN host. + * @param string|null $user + * The mailer DSN username. + * @param string|null $password + * The mailer DSN password. + * @param int|null $port + * The mailer DSN port. + * @param array<string, mixed> $options + * Options for the mailer transport. + */ + protected function setUpMailerDsnConfigOverride( + string $scheme, + string $host, + ?string $user = NULL, + #[\SensitiveParameter] ?string $password = NULL, + ?int $port = NULL, + array $options = [], + ): void { + $GLOBALS['config']['system.mail']['mailer_dsn'] = [ + 'scheme' => $scheme, + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'port' => $port, + 'options' => $options, + ]; + } + + /** + * Resets a mailer DSN config override. + * + * Clean up the globals modified by setUpMailerDsnConfigOverride() during a + * test. + */ + #[After] + protected function resetMailerDsnConfigOverride(): void { + $this->setUpMailerDsnConfigOverride('null', 'null'); + } + + /** + * @covers ::createTransport + */ + public function testDefaultTestMailFactory(): void { + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(NullTransport::class, $actual); + } + + /** + * @dataProvider providerTestBuiltinFactory + * @covers ::createTransport + */ + public function testBuiltinFactory(string $schema, string $host, string $expected): void { + $this->setUpMailerDsnConfigOverride($schema, $host); + + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf($expected, $actual); + } + + /** + * Provides test data for testBuiltinFactory(). + */ + public static function providerTestBuiltinFactory(): iterable { + yield ['null', 'null', NullTransport::class]; + yield ['sendmail', 'default', SendmailTransport::class]; + yield ['smtp', 'default', EsmtpTransport::class]; + } + + /** + * @covers ::createTransport + * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create + */ + public function testSendmailFactoryAllowedCommand(): void { + // Test sendmail command allowlist. + $settings = Settings::getAll(); + $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs']; + new Settings($settings); + + // Test allowlisted command. + $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [ + 'command' => '/usr/local/bin/sendmail -bs', + ]); + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(SendmailTransport::class, $actual); + } + + /** + * @covers ::createTransport + * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create + */ + public function testSendmailFactoryUnlistedCommand(): void { + // Test sendmail command allowlist. + $settings = Settings::getAll(); + $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs']; + new Settings($settings); + + // Test unlisted command. + $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [ + 'command' => '/usr/bin/bc', + ]); + $this->expectExceptionMessage('Unsafe sendmail command /usr/bin/bc'); + $this->container->get(TransportInterface::class); + } + + /** + * @covers ::createTransport + */ + public function testMissingFactory(): void { + $this->setUpMailerDsnConfigOverride('drupal.no-transport', 'default'); + + $this->expectExceptionMessage('The "drupal.no-transport" scheme is not supported'); + $this->container->get(TransportInterface::class); + } + + /** + * @covers ::createTransport + */ + public function testThirdPartyFactory(): void { + $this->enableModules(['mailer_transport_factory_kernel_test']); + + $this->setUpMailerDsnConfigOverride('drupal.test-canary', 'default'); + + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(CanaryTransport::class, $actual); + } + +} diff --git a/core/modules/media/src/Hook/MediaRequirementsHooks.php b/core/modules/media/src/Hook/MediaRequirementsHooks.php index cedbb2fd820..f431134b6f4 100644 --- a/core/modules/media/src/Hook/MediaRequirementsHooks.php +++ b/core/modules/media/src/Hook/MediaRequirementsHooks.php @@ -4,6 +4,7 @@ namespace Drupal\media\Hook; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -45,7 +46,7 @@ class MediaRequirementsHooks { '%type' => $type->label(), ] ), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -88,7 +89,7 @@ class MediaRequirementsHooks { '%type' => $type->label(), ] ), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/media/src/Install/Requirements/MediaRequirements.php b/core/modules/media/src/Install/Requirements/MediaRequirements.php index a69a79aaf81..9fa100ab974 100644 --- a/core/modules/media/src/Install/Requirements/MediaRequirements.php +++ b/core/modules/media/src/Install/Requirements/MediaRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\media\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\FileSystemInterface; /** @@ -31,7 +32,7 @@ class MediaRequirements implements InstallRequirementsInterface { $description = t('An automated attempt to create this directory failed, possibly due to a permissions problem. To proceed with the installation, either create the directory and modify its permissions manually or ensure that the installer has the permissions to create it automatically. For more information, see INSTALL.txt or the <a href=":handbook_url">online handbook</a>.', [':handbook_url' => 'https://www.drupal.org/server-permissions']); $description = $error . ' ' . $description; $requirements['media']['description'] = $description; - $requirements['media']['severity'] = REQUIREMENT_ERROR; + $requirements['media']['severity'] = RequirementSeverity::Error; } return $requirements; } diff --git a/core/modules/media_library/config/install/image.style.media_library.yml b/core/modules/media_library/config/install/image.style.media_library.yml index 5da64cfdcc3..4383a8c2cba 100644 --- a/core/modules/media_library/config/install/image.style.media_library.yml +++ b/core/modules/media_library/config/install/image.style.media_library.yml @@ -17,7 +17,7 @@ effects: upscale: false 1021da71-fc2a-43d0-be5d-efaf1c79e2ea: uuid: 1021da71-fc2a-43d0-be5d-efaf1c79e2ea - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php index 3a1cb8a1b69..77c8b45d00f 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php @@ -609,15 +609,15 @@ abstract class SourcePluginBase extends PluginBase implements MigrateSourceInter * {@inheritdoc} */ public function preRollback(MigrateRollbackEvent $event) { - // Nothing to do in this implementation. + // Reset the high-water mark. + $this->saveHighWater(NULL); } /** * {@inheritdoc} */ public function postRollback(MigrateRollbackEvent $event) { - // Reset the high-water mark. - $this->saveHighWater(NULL); + // Nothing to do in this implementation. } /** diff --git a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php index 2f0b85ffbc4..e344e3e23e8 100644 --- a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php +++ b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php @@ -9,6 +9,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; use Drupal\Core\KeyValueStore\KeyValueStoreInterface; +use Drupal\migrate\Event\MigrateRollbackEvent; use Drupal\migrate\MigrateException; use Drupal\migrate\MigrateExecutable; use Drupal\migrate\MigrateSkipRowException; @@ -448,6 +449,32 @@ class MigrateSourceTest extends MigrateTestCase { return new MigrateExecutable($migration, $message, $event_dispatcher); } + /** + * @covers ::preRollback + */ + public function testPreRollback(): void { + $this->migrationConfiguration['id'] = 'test_migration'; + $plugin_id = 'test_migration'; + $migration = $this->getMigration(); + + // Verify that preRollback() sets the high water mark to NULL. + $key_value = $this->createMock(KeyValueStoreInterface::class); + $key_value->expects($this->once()) + ->method('set') + ->with($plugin_id, NULL); + $key_value_factory = $this->createMock(KeyValueFactoryInterface::class); + $key_value_factory->expects($this->once()) + ->method('get') + ->with('migrate:high_water') + ->willReturn($key_value); + $container = new ContainerBuilder(); + $container->set('keyvalue', $key_value_factory); + \Drupal::setContainer($container); + + $source = new StubSourceGeneratorPlugin([], $plugin_id, [], $migration); + $source->preRollback(new MigrateRollbackEvent($migration)); + } + } /** diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php index 1f54f94848e..efe2b150928 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php @@ -18,6 +18,7 @@ use Drupal\field_discovery_test\FieldDiscoveryTestClass; * Test FieldDiscovery Service against Drupal 7. * * @group migrate_drupal + * @group #slow * @coversDefaultClass \Drupal\migrate_drupal\FieldDiscovery */ class FieldDiscoveryTest extends MigrateDrupal7TestBase { diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php index ca8a9a0d06b..27ab60bc0c0 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php @@ -16,6 +16,7 @@ use Drupal\Tests\migrate_drupal\Traits\CreateTestContentEntitiesTrait; * Tests the migration auditor for ID conflicts. * * @group migrate_drupal + * @group #slow */ class MigrateDrupal7AuditIdsTest extends MigrateDrupal7TestBase { diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php index 64dc7a1ea86..daf06a65468 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php @@ -73,7 +73,7 @@ class Upgrade6Test extends MigrateUpgradeExecuteTestBase { */ protected function getEntityCounts(): array { return [ - 'block' => 37, + 'block' => 36, 'block_content' => 2, 'block_content_type' => 1, 'comment' => 8, diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php index 46b3447e159..f9b702d22e3 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php @@ -76,7 +76,7 @@ class Upgrade7Test extends MigrateUpgradeExecuteTestBase { */ protected function getEntityCounts(): array { return [ - 'block' => 27, + 'block' => 26, 'block_content' => 1, 'block_content_type' => 1, 'comment' => 4, diff --git a/core/modules/mysql/src/Hook/MysqlRequirements.php b/core/modules/mysql/src/Hook/MysqlRequirements.php index ef305d41a34..c3dfb10ca43 100644 --- a/core/modules/mysql/src/Hook/MysqlRequirements.php +++ b/core/modules/mysql/src/Hook/MysqlRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\mysql\Hook; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Render\Markup; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,18 +47,18 @@ class MysqlRequirements { $description = []; if ($isolation_level == 'READ-COMMITTED') { if (empty($tables_missing_primary_key)) { - $severity_level = REQUIREMENT_OK; + $severity_level = RequirementSeverity::OK; } else { - $severity_level = REQUIREMENT_ERROR; + $severity_level = RequirementSeverity::Error; } } else { if ($isolation_level == 'REPEATABLE-READ') { - $severity_level = REQUIREMENT_WARNING; + $severity_level = RequirementSeverity::Warning; } else { - $severity_level = REQUIREMENT_ERROR; + $severity_level = RequirementSeverity::Error; $description[] = $this->t('This is not supported by Drupal.'); } $description[] = $this->t('The recommended level for Drupal is "READ COMMITTED".'); diff --git a/core/modules/navigation/src/Hook/NavigationRequirements.php b/core/modules/navigation/src/Hook/NavigationRequirements.php index ae04608bfd6..f72877c04e4 100644 --- a/core/modules/navigation/src/Hook/NavigationRequirements.php +++ b/core/modules/navigation/src/Hook/NavigationRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\navigation\Hook; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -29,7 +30,7 @@ class NavigationRequirements { $requirements['toolbar'] = [ 'title' => $this->t('Toolbar and Navigation modules are both installed'), 'value' => $this->t('The Navigation module is a complete replacement for the Toolbar module and disables its functionality when both modules are installed. If you are planning to continue using Navigation module, you can uninstall the Toolbar module now.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } return $requirements; diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index 2371bef31aa..5bf9d2477f0 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -73,14 +73,14 @@ class PerformanceTest extends PerformanceTestBase { $expected = [ 'QueryCount' => 4, - 'CacheGetCount' => 48, + 'CacheGetCount' => 47, 'CacheGetCountByBin' => [ 'config' => 11, 'data' => 4, 'discovery' => 10, 'bootstrap' => 6, 'dynamic_page_cache' => 1, - 'render' => 15, + 'render' => 14, 'menu' => 1, ], 'CacheSetCount' => 2, @@ -89,9 +89,9 @@ class PerformanceTest extends PerformanceTestBase { ], 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'ScriptCount' => 3, - 'ScriptBytes' => 213500, + 'ScriptBytes' => 167569, 'StylesheetCount' => 2, 'StylesheetBytes' => 46000, ]; diff --git a/core/modules/node/js/node.preview.js b/core/modules/node/js/node.preview.js index 50bc58ade77..e23be0b71e2 100644 --- a/core/modules/node/js/node.preview.js +++ b/core/modules/node/js/node.preview.js @@ -34,13 +34,13 @@ const $previewDialog = $( `<div>${Drupal.theme('nodePreviewModal')}</div>`, ).appendTo('body'); - Drupal.dialog($previewDialog, { + const confirmationDialog = Drupal.dialog($previewDialog, { title: Drupal.t('Leave preview?'), buttons: [ { text: Drupal.t('Cancel'), click() { - $(this).dialog('close'); + confirmationDialog.close(); }, }, { @@ -50,7 +50,8 @@ }, }, ], - }).showModal(); + }); + confirmationDialog.showModal(); } } diff --git a/core/modules/node/src/Controller/NodeController.php b/core/modules/node/src/Controller/NodeController.php index e860d0c1d2a..d5a35f64285 100644 --- a/core/modules/node/src/Controller/NodeController.php +++ b/core/modules/node/src/Controller/NodeController.php @@ -3,7 +3,6 @@ namespace Drupal\node\Controller; use Drupal\Component\Utility\Xss; -use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -197,10 +196,12 @@ class NodeController extends ControllerBase implements ContainerInjectionInterfa 'username' => $this->renderer->renderInIsolation($username), 'message' => ['#markup' => $revision->revision_log->value, '#allowed_tags' => Xss::getHtmlTagList()], ], + // @todo Fix this properly in https://www.drupal.org/project/drupal/issues/3227637. + '#cache' => [ + 'max-age' => 0, + ], ], ]; - // @todo Simplify once https://www.drupal.org/node/2334319 lands. - $this->renderer->addCacheableDependency($column['data'], CacheableMetadata::createFromRenderArray($username)); $row[] = $column; if ($is_current_revision) { diff --git a/core/modules/node/src/Hook/NodeHooks.php b/core/modules/node/src/Hook/NodeHooks.php index d5f84e0359b..8a6b4d887c8 100644 --- a/core/modules/node/src/Hook/NodeHooks.php +++ b/core/modules/node/src/Hook/NodeHooks.php @@ -66,4 +66,13 @@ class NodeHooks { } } + /** + * Implements hook_block_alter(). + */ + #[Hook('block_alter')] + public function blockAlter(&$definitions): void { + // Hide the deprecated Syndicate block from the UI. + $definitions['node_syndicate_block']['_block_ui_hidden'] = TRUE; + } + } diff --git a/core/modules/node/src/Hook/NodeRequirements.php b/core/modules/node/src/Hook/NodeRequirements.php index aa8b39d5682..84f74aee98c 100644 --- a/core/modules/node/src/Hook/NodeRequirements.php +++ b/core/modules/node/src/Hook/NodeRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\node\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Extension\ModuleExtensionList; @@ -144,7 +145,7 @@ class NodeRequirements { 'title' => $this->t('Content status filter'), 'value' => $this->t('Redundant filters detected'), 'description' => $node_status_filter_description, - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } diff --git a/core/modules/node/src/Hook/NodeThemeHooks.php b/core/modules/node/src/Hook/NodeThemeHooks.php index 7ee443c458f..7ed0ef91f5f 100644 --- a/core/modules/node/src/Hook/NodeThemeHooks.php +++ b/core/modules/node/src/Hook/NodeThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\node\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for the node module. @@ -14,7 +14,7 @@ class NodeThemeHooks { /** * Implements hook_preprocess_HOOK() for node field templates. */ - #[Preprocess('field__node')] + #[Hook('preprocess_field__node')] public function preprocessFieldNode(&$variables): void { // Set a variable 'is_inline' in cases where inline markup is required, // without any block elements such as <div>. diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index 963ab53ded4..7121f62e283 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -223,7 +223,16 @@ class NodeAccessControlHandler extends EntityAccessControlHandler implements Nod return NULL; } + // When access is granted due to the 'view own unpublished content' + // permission and for no other reason, node grants are bypassed. However, + // to ensure the full set of cacheable metadata is available to variation + // cache, additionally add the node_grants cache context so that if the + // status or the owner of the node changes, cache redirects will continue to + // reflect the latest state without needing to be invalidated. $cacheability->addCacheContexts(['user']); + if ($this->moduleHandler->hasImplementations('node_grants')) { + $cacheability->addCacheContexts(['user.node_grants:view']); + } if ($account->id() != $node->getOwnerId()) { return NULL; } diff --git a/core/modules/node/src/NodePermissions.php b/core/modules/node/src/NodePermissions.php index e913f5326f3..5f651830192 100644 --- a/core/modules/node/src/NodePermissions.php +++ b/core/modules/node/src/NodePermissions.php @@ -2,6 +2,9 @@ namespace Drupal\node; +use Drupal\Core\DependencyInjection\AutowireTrait; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\BundlePermissionHandlerTrait; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\node\Entity\NodeType; @@ -9,19 +12,34 @@ use Drupal\node\Entity\NodeType; /** * Provides dynamic permissions for nodes of different types. */ -class NodePermissions { +class NodePermissions implements ContainerInjectionInterface { + + use AutowireTrait; use BundlePermissionHandlerTrait; use StringTranslationTrait; + public function __construct( + protected ?EntityTypeManagerInterface $entityTypeManager = NULL, + ) { + if ($entityTypeManager === NULL) { + @trigger_error('Calling ' . __METHOD__ . ' without the $entityTypeManager argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3515921', E_USER_DEPRECATED); + $this->entityTypeManager = \Drupal::entityTypeManager(); + } + } + /** * Returns an array of node type permissions. * * @return array * The node type permissions. - * @see \Drupal\user\PermissionHandlerInterface::getPermissions() + * + * @see \Drupal\user\PermissionHandlerInterface::getPermissions() */ public function nodeTypePermissions() { - return $this->generatePermissions(NodeType::loadMultiple(), [$this, 'buildPermissions']); + return $this->generatePermissions( + $this->entityTypeManager->getStorage('node_type')->loadMultiple(), + [$this, 'buildPermissions'] + ); } /** diff --git a/core/modules/node/src/Plugin/Block/SyndicateBlock.php b/core/modules/node/src/Plugin/Block/SyndicateBlock.php index b10c63527e5..45cfe1eb45c 100644 --- a/core/modules/node/src/Plugin/Block/SyndicateBlock.php +++ b/core/modules/node/src/Plugin/Block/SyndicateBlock.php @@ -14,6 +14,11 @@ use Drupal\Core\Url; /** * Provides a 'Syndicate' block that links to the site's RSS feed. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3519248 */ #[Block( id: "node_syndicate_block", @@ -43,6 +48,7 @@ class SyndicateBlock extends BlockBase implements ContainerFactoryPluginInterfac * The config factory. */ public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactoryInterface $configFactory) { + @trigger_error('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248', E_USER_DEPRECATED); parent::__construct($configuration, $plugin_id, $plugin_definition); $this->configFactory = $configFactory; } diff --git a/core/modules/node/src/Plugin/views/UidRevisionTrait.php b/core/modules/node/src/Plugin/views/UidRevisionTrait.php new file mode 100644 index 00000000000..5cbf21d56d4 --- /dev/null +++ b/core/modules/node/src/Plugin/views/UidRevisionTrait.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\node\Plugin\views; + +/** + * Checks for nodes that a user posted or created a revision on. + */ +trait UidRevisionTrait { + + /** + * Checks for nodes that a user posted or created a revision on. + * + * @param array $uids + * A list of user ids. + * @param int $group + * See \Drupal\views\Plugin\views\query\Sql::addWhereExpression() $group. + */ + public function uidRevisionQuery(array $uids, int $group = 0): void { + $this->ensureMyTable(); + + // As per https://www.php.net/manual/en/pdo.prepare.php "you cannot use a + // named parameter marker of the same name more than once in a prepared + // statement". + $placeholder_1 = $this->placeholder() . '[]'; + $placeholder_2 = $this->placeholder() . '[]'; + + $args = array_values($uids); + + $this->query->addWhereExpression($group, "$this->tableAlias.uid IN ($placeholder_1) OR + EXISTS (SELECT 1 FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder_2) AND nr.nid = $this->tableAlias.nid)", [ + $placeholder_1 => $args, + $placeholder_2 => $args, + ]); + } + +} diff --git a/core/modules/node/src/Plugin/views/argument/UidRevision.php b/core/modules/node/src/Plugin/views/argument/UidRevision.php index 982152080a6..9be0cc9d7b6 100644 --- a/core/modules/node/src/Plugin/views/argument/UidRevision.php +++ b/core/modules/node/src/Plugin/views/argument/UidRevision.php @@ -2,6 +2,7 @@ namespace Drupal\node\Plugin\views\argument; +use Drupal\node\Plugin\views\UidRevisionTrait; use Drupal\user\Plugin\views\argument\Uid; use Drupal\views\Attribute\ViewsArgument; @@ -15,13 +16,13 @@ use Drupal\views\Attribute\ViewsArgument; )] class UidRevision extends Uid { + use UidRevisionTrait; + /** * {@inheritdoc} */ public function query($group_by = FALSE) { - $this->ensureMyTable(); - $placeholder = $this->placeholder(); - $this->query->addWhereExpression(0, "$this->tableAlias.uid = $placeholder OR ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid = $placeholder AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $this->argument]); + $this->uidRevisionQuery([$this->argument]); } } diff --git a/core/modules/node/src/Plugin/views/filter/UidRevision.php b/core/modules/node/src/Plugin/views/filter/UidRevision.php index b7f186fa07d..cf962a2897e 100644 --- a/core/modules/node/src/Plugin/views/filter/UidRevision.php +++ b/core/modules/node/src/Plugin/views/filter/UidRevision.php @@ -2,6 +2,7 @@ namespace Drupal\node\Plugin\views\filter; +use Drupal\node\Plugin\views\UidRevisionTrait; use Drupal\user\Plugin\views\filter\Name; use Drupal\views\Attribute\ViewsFilter; @@ -13,19 +14,13 @@ use Drupal\views\Attribute\ViewsFilter; #[ViewsFilter("node_uid_revision")] class UidRevision extends Name { + use UidRevisionTrait; + /** * {@inheritdoc} */ public function query($group_by = FALSE) { - $this->ensureMyTable(); - - $placeholder = $this->placeholder() . '[]'; - - $args = array_values($this->value); - - $this->query->addWhereExpression($this->options['group'], "$this->tableAlias.uid IN($placeholder) OR - ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder) AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $args], - $args); + $this->uidRevisionQuery($this->value, $this->options['group']); } } diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php new file mode 100644 index 00000000000..0d49a7c416c --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +/** + * Tests the node access grants cache context service. + * + * @group node + * @group Cache + */ +class NodeAccessCacheRedirectWarningTest extends NodeTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'node_access_test_empty']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + node_access_rebuild(); + } + + /** + * Ensures that node access checks don't cause cache redirect warnings. + * + * @covers \Drupal\node\NodeAccessControlHandler + */ + public function testNodeAccessCacheRedirectWarning(): void { + $this->drupalPlaceBlock('local_tasks_block'); + + // Ensure that both a node_grants implementation exists, and that the + // current user has 'view own unpublished nodes' permission. Node's access + // control handler bypasses node grants when 'view own published nodes' is + // granted and the node is unpublished, which means that the code path is + // significantly different when a node is published vs. unpublished, and + // that cache contexts vary depend on the state of the node. + $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('node_grants')); + + $author = $this->drupalCreateUser([ + 'create page content', + 'edit any page content', + 'view own unpublished content', + ]); + $this->drupalLogin($author); + + $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + // When the node has been viewed in both the unpublished and published state + // a cache redirect should exist for the local tasks block. Repeating the + // process of changing the node status and viewing the node will test that + // no stale redirect is found. + $node->setUnpublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + } + +} diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php new file mode 100644 index 00000000000..5a930df3e2d --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +use Drupal\Core\Url; + +/** + * Tests reverting node revisions correctly sets authorship information. + * + * @group node + */ +class NodeRevisionsAuthorTest extends NodeTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests node authorship is retained after reverting revisions. + */ + public function testNodeRevisionRevertAuthors(): void { + // Create and log in user. + $initialUser = $this->drupalCreateUser([ + 'view page revisions', + 'revert page revisions', + 'edit any page content', + ]); + $initialRevisionUser = $this->drupalCreateUser(); + // Third user is an author only and needs no permissions + $initialRevisionAuthor = $this->drupalCreateUser(); + + // Create initial node (author: $user1). + $this->drupalLogin($initialUser); + $node = $this->drupalCreateNode(); + $originalRevisionId = $node->getRevisionId(); + $originalBody = $node->body->value; + $originalTitle = $node->getTitle(); + + // Create a revision (as $initialUser) showing $initialRevisionAuthor + // as author. + $node->setRevisionLogMessage('Changed author'); + $revisedTitle = $this->randomMachineName(); + $node->setTitle($revisedTitle); + $revisedBody = $this->randomMachineName(32); + $node->set('body', [ + 'value' => $revisedBody, + 'format' => filter_default_format(), + ]); + $node->setOwnerId($initialRevisionAuthor->id()); + $node->setRevisionUserId($initialRevisionUser->id()); + $node->setNewRevision(); + $node->save(); + $revisedRevisionId = $node->getRevisionId(); + + $nodeStorage = \Drupal::entityTypeManager()->getStorage('node'); + + self::assertEquals($node->getOwnerId(), $initialRevisionAuthor->id()); + self::assertEquals($node->getRevisionUserId(), $initialRevisionUser->id()); + + // Revert to the original node revision. + $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [ + 'node' => $node->id(), + 'node_revision' => $originalRevisionId, + ])); + $this->submitForm([], 'Revert'); + $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $originalTitle)); + + // With the revert done, reload the node and verify that the authorship + // fields have reverted correctly. + $nodeStorage->resetCache([$node->id()]); + /** @var \Drupal\node\NodeInterface $revertedNode */ + $revertedNode = $nodeStorage->load($node->id()); + self::assertEquals($originalBody, $revertedNode->body->value); + self::assertEquals($initialUser->id(), $revertedNode->getOwnerId()); + self::assertEquals($initialUser->id(), $revertedNode->getRevisionUserId()); + + // Revert again to the revised version and check that node author and + // revision author fields are correct. + // Revert to the original node. + $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [ + 'node' => $revertedNode->id(), + 'node_revision' => $revisedRevisionId, + ])); + $this->submitForm([], 'Revert'); + $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $revisedTitle)); + + // With the reversion done, reload the node and verify that the + // authorship fields have reverted correctly. + $nodeStorage->resetCache([$revertedNode->id()]); + /** @var \Drupal\node\NodeInterface $re_reverted_node */ + $re_reverted_node = $nodeStorage->load($revertedNode->id()); + self::assertEquals($revisedBody, $re_reverted_node->body->value); + self::assertEquals($initialRevisionAuthor->id(), $re_reverted_node->getOwnerId()); + // The new revision user will be the current logged in user as set in + // NodeRevisionRevertForm. + self::assertEquals($initialUser->id(), $re_reverted_node->getRevisionUserId()); + } + +} diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php index 201d4b6c7d2..88fe3e34e3e 100644 --- a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php +++ b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php @@ -215,20 +215,4 @@ class NodeRevisionsUiTest extends NodeTestBase { $this->assertSession()->elementsCount('xpath', $xpath, 1); } - /** - * Tests the node revisions page is cacheable by dynamic page cache. - */ - public function testNodeRevisionsCacheability(): void { - $this->drupalLogin($this->editor); - $node = $this->drupalCreateNode(); - // Admin paths are always uncacheable by dynamic page cache, swap node - // to non admin theme to test cacheability. - $this->config('node.settings')->set('use_admin_theme', FALSE)->save(); - \Drupal::service('router.builder')->rebuild(); - $this->drupalGet($node->toUrl('version-history')); - $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'MISS'); - $this->drupalGet($node->toUrl('version-history')); - $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'HIT'); - } - } diff --git a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php index c3a3d46b496..f8d52b06ecb 100644 --- a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php +++ b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\node\Functional; * Tests if the syndicate block is available. * * @group node + * @group legacy */ class NodeSyndicateBlockTest extends NodeTestBase { @@ -40,6 +41,7 @@ class NodeSyndicateBlockTest extends NodeTestBase { $this->drupalPlaceBlock('node_syndicate_block', ['id' => 'test_syndicate_block', 'label' => 'Subscribe to RSS Feed']); $this->drupalGet(''); $this->assertSession()->elementExists('xpath', '//div[@id="block-test-syndicate-block"]/*'); + $this->expectDeprecation('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248'); // Verify syndicate block title. $this->assertSession()->pageTextContains('Subscribe to RSS Feed'); diff --git a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php index 2bb252f7c6e..ac1e8664bad 100644 --- a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php +++ b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php @@ -242,21 +242,19 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { // Set up the default admin theme and use it for node editing. $this->container->get('theme_installer')->install(['claro']); - $edit = []; - $edit['admin_theme'] = 'claro'; - $edit['use_admin_theme'] = TRUE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('system.theme')->set('admin', 'claro')->save(); + // Verify that translation uses the admin theme if edit is admin. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseContains('core/themes/claro/css/base/elements.css'); // Turn off admin theme for editing, assert inheritance to translations. - $edit['use_admin_theme'] = FALSE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('node.settings')->set('use_admin_theme', FALSE)->save(); + // Changing node.settings:use_admin_theme requires a route rebuild. + $this->container->get('router.builder')->rebuild(); + // Verify that translation uses the frontend theme if edit is frontend. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseNotContains('core/themes/claro/css/base/elements.css'); // Assert presence of translation page itself (vs. DisabledBundle below). @@ -561,12 +559,10 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { 'translatable' => TRUE, ])->save(); - $this->drupalLogin($this->administrator); // Make the image field a multi-value field in order to display a // details form element. - $edit = ['field_storage[subform][cardinality_number]' => 2]; - $this->drupalGet('admin/structure/types/manage/article/fields/node.article.field_image'); - $this->submitForm($edit, 'Save'); + $fieldStorage = FieldStorageConfig::loadByName('node', 'field_image'); + $fieldStorage->setCardinality(2)->save(); // Enable the display of the image field. EntityFormDisplay::load('node.article.default') diff --git a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php index cbe9b346623..ac47588d5ec 100644 --- a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php +++ b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php @@ -18,6 +18,7 @@ use Drupal\Tests\migrate_drupal\Traits\NodeMigrateTypeTestTrait; * Test class for a complete node migration for Drupal 7. * * @group migrate_drupal_7 + * @group #slow */ class MigrateNodeCompleteTest extends MigrateDrupal7TestBase { diff --git a/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php b/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php index 60ce5c7cdb0..b86b69e8ad1 100644 --- a/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php +++ b/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\node\Kernel; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\KernelTests\KernelTestBase; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\views\Entity\View; @@ -77,7 +78,7 @@ class NodeRequirementsStatusFilterWarningTest extends KernelTestBase { $requirements = $this->getRequirements(); $this->assertArrayHasKey('node_status_filter', $requirements); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['node_status_filter']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['node_status_filter']['severity']); } /** @@ -102,7 +103,7 @@ class NodeRequirementsStatusFilterWarningTest extends KernelTestBase { $requirements = $this->getRequirements(); $this->assertArrayHasKey('node_status_filter', $requirements); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['node_status_filter']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['node_status_filter']['severity']); } /** diff --git a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php index 80f92c2d286..28dc50ef60f 100644 --- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php +++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php @@ -321,13 +321,7 @@ JS; ], 'List string' => [ 'list_string', - ['first' => 'First', 'second' => 'Second', 'third' => 'Third'], - TRUE, - ], - // Example with empty key and label values like string '0'. - 'List string with 0 value' => [ - 'list_string', - ['0' => '0', '1' => '1', '2' => '2'], + ['0' => '0', '1' => '1', 'two' => 'two'], TRUE, ], ]; diff --git a/core/modules/package_manager/package_manager.api.php b/core/modules/package_manager/package_manager.api.php index 216737e1573..9fa34742ef9 100644 --- a/core/modules/package_manager/package_manager.api.php +++ b/core/modules/package_manager/package_manager.api.php @@ -95,6 +95,8 @@ * for event subscribers to flag errors before the active directory is * modified, because once that has happened, the changes cannot be undone. * This event may be dispatched multiple times during the stage life cycle. + * Note that this event is NOT dispatched when the sandbox manager is + * operating in direct-write mode. * * - \Drupal\package_manager\Event\PostApplyEvent * Dispatched after changes in the stage directory have been copied to the @@ -109,6 +111,11 @@ * life cycle, and should *never* be used for schema changes (i.e., operations * that should happen in `hook_update_N()` or a post-update function). * + * Since the apply events are not dispatched in direct-write mode, event + * subscribers that want to prevent a sandbox from moving through its life cycle + * in direct-write mode should do it by subscribing to PreCreateEvent or + * StatusCheckEvent. + * * @section sec_stage_api Stage API: Public methods * The public API of any stage consists of the following methods: * diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml index 54c8fb846e0..d7bbaf94820 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -47,6 +47,7 @@ services: Drupal\package_manager\EventSubscriber\ChangeLogger: calls: - [setLogger, ['@logger.channel.package_manager_change_log']] + Drupal\package_manager\EventSubscriber\DirectWriteSubscriber: {} Drupal\package_manager\ComposerInspector: {} # Validators. @@ -201,3 +202,9 @@ services: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxyInterface: class: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxy public: false + + Drupal\package_manager\DirectWritePreconditionBypass: + decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface' + arguments: + - '@.inner' + public: false diff --git a/core/modules/package_manager/src/Attribute/AllowDirectWrite.php b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php new file mode 100644 index 00000000000..d41de1a87e4 --- /dev/null +++ b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Attribute; + +/** + * Identifies sandbox managers which can operate on the running code base. + * + * Package Manager normally creates and operates on a fully separate, sandboxed + * copy of the site. This is pretty safe, but not always necessary for certain + * kinds of operations (e.g., adding a new module to the site). + * SandboxManagerBase subclasses with this attribute are allowed to skip the + * sandboxing and operate directly on the live site, but ONLY if the + * `package_manager_allow_direct_write` setting is set to TRUE. + * + * @see \Drupal\package_manager\SandboxManagerBase::isDirectWrite() + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class AllowDirectWrite { +} diff --git a/core/modules/package_manager/src/ComposerInspector.php b/core/modules/package_manager/src/ComposerInspector.php index 69d30738850..32bde1002ea 100644 --- a/core/modules/package_manager/src/ComposerInspector.php +++ b/core/modules/package_manager/src/ComposerInspector.php @@ -54,7 +54,7 @@ class ComposerInspector implements LoggerAwareInterface { * * @var string */ - final public const SUPPORTED_VERSION = '^2.6'; + final public const SUPPORTED_VERSION = '^2.7'; public function __construct( private readonly ComposerProcessRunnerInterface $runner, diff --git a/core/modules/package_manager/src/DirectWritePreconditionBypass.php b/core/modules/package_manager/src/DirectWritePreconditionBypass.php new file mode 100644 index 00000000000..ba456d270d7 --- /dev/null +++ b/core/modules/package_manager/src/DirectWritePreconditionBypass.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; + +/** + * Allows certain Composer Stager preconditions to be bypassed. + * + * Only certain preconditions can be bypassed; this class implements all of + * those interfaces, and only accepts them in its constructor. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class DirectWritePreconditionBypass implements ActiveAndStagingDirsAreDifferentInterface { + + use StringTranslationTrait; + + /** + * Whether or not the decorated precondition is being bypassed. + * + * @var bool + */ + private static bool $isBypassed = FALSE; + + public function __construct( + private readonly ActiveAndStagingDirsAreDifferentInterface $decorated, + ) {} + + /** + * Bypasses the decorated precondition. + */ + public static function activate(): void { + static::$isBypassed = TRUE; + } + + /** + * {@inheritdoc} + */ + public function getName(): TranslatableInterface { + return $this->decorated->getName(); + } + + /** + * {@inheritdoc} + */ + public function getDescription(): TranslatableInterface { + return $this->decorated->getDescription(); + } + + /** + * {@inheritdoc} + */ + public function getStatusMessage(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): TranslatableInterface { + if (static::$isBypassed) { + return new TranslatableStringAdapter('This precondition has been skipped because it is not needed in direct-write mode.'); + } + return $this->decorated->getStatusMessage($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function isFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): bool { + if (static::$isBypassed) { + return TRUE; + } + return $this->decorated->isFulfilled($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function assertIsFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + if (static::$isBypassed) { + return; + } + $this->decorated->assertIsFulfilled($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function getLeaves(): array { + return [$this]; + } + +} diff --git a/core/modules/package_manager/src/Event/SandboxValidationEvent.php b/core/modules/package_manager/src/Event/SandboxValidationEvent.php index 0dad6829486..df5bc1c2bbc 100644 --- a/core/modules/package_manager/src/Event/SandboxValidationEvent.php +++ b/core/modules/package_manager/src/Event/SandboxValidationEvent.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\package_manager\Event; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\package_manager\ValidationResult; -use Drupal\system\SystemManager; /** * Base class for events dispatched before a stage life cycle operation. @@ -75,7 +75,7 @@ abstract class SandboxValidationEvent extends SandboxEvent { */ public function addResult(ValidationResult $result): void { // Only errors are allowed for this event. - if ($result->severity !== SystemManager::REQUIREMENT_ERROR) { + if ($result->severity !== RequirementSeverity::Error->value) { throw new \InvalidArgumentException('Only errors are allowed.'); } $this->results[] = $result; @@ -85,7 +85,7 @@ abstract class SandboxValidationEvent extends SandboxEvent { * {@inheritdoc} */ public function stopPropagation(): void { - if (empty($this->getResults(SystemManager::REQUIREMENT_ERROR))) { + if (empty($this->getResults(RequirementSeverity::Error->value))) { $this->addErrorFromThrowable(new \LogicException('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.')); } parent::stopPropagation(); diff --git a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php index 703dbf4603b..c8c19324c87 100644 --- a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php +++ b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php @@ -85,15 +85,21 @@ final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterfa $event->getDevPackages(), ); $event->sandboxManager->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages); + + // If we're in direct-write mode, the changes have already been made, so + // we should log them right away. + if ($event->sandboxManager->isDirectWrite()) { + $this->logChanges($event); + } } /** * Logs changes made by Package Manager. * - * @param \Drupal\package_manager\Event\PostApplyEvent $event + * @param \Drupal\package_manager\Event\PostApplyEvent|\Drupal\package_manager\Event\PostRequireEvent $event * The event being handled. */ - public function logChanges(PostApplyEvent $event): void { + public function logChanges(PostApplyEvent|PostRequireEvent $event): void { $installed_at_start = $event->sandboxManager->getMetadata(static::INSTALLED_PACKAGES_KEY); $installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); diff --git a/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php b/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php new file mode 100644 index 00000000000..c2340c39783 --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Handles sandbox events when direct-write is enabled. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class DirectWriteSubscriber implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The state key which holds the original status of maintenance mode. + * + * @var string + */ + private const STATE_KEY = 'package_manager.maintenance_mode'; + + public function __construct(private readonly StateInterface $state) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'warnAboutDirectWrite', + // We want to go into maintenance mode after other subscribers, to give + // them a chance to flag errors. + PreRequireEvent::class => ['enterMaintenanceMode', -10000], + // We want to exit maintenance mode as early as possible. + PostRequireEvent::class => ['exitMaintenanceMode', 10000], + ]; + } + + /** + * Logs a warning about direct-write mode, if it is in use. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent $event + * The event being handled. + */ + public function warnAboutDirectWrite(StatusCheckEvent $event): void { + if ($event->sandboxManager->isDirectWrite()) { + $event->addWarning([ + $this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'), + ]); + } + } + + /** + * Enters maintenance mode before a direct-mode require operation. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The event being handled. + */ + public function enterMaintenanceMode(PreRequireEvent $event): void { + $errors = $event->getResults(RequirementSeverity::Error->value); + + if (empty($errors) && $event->sandboxManager->isDirectWrite()) { + $this->state->set(static::STATE_KEY, (bool) $this->state->get('system.maintenance_mode')); + $this->state->set('system.maintenance_mode', TRUE); + } + } + + /** + * Leaves maintenance mode after a direct-mode require operation. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The event being handled. + */ + public function exitMaintenanceMode(PostRequireEvent $event): void { + if ($event->sandboxManager->isDirectWrite()) { + $this->state->set('system.maintenance_mode', $this->state->get(static::STATE_KEY)); + $this->state->delete(static::STATE_KEY); + } + } + +} diff --git a/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php b/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php index 4d315a94330..52cc10bc4e9 100644 --- a/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php +++ b/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php @@ -2,6 +2,7 @@ namespace Drupal\package_manager\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Site\Settings; @@ -41,7 +42,7 @@ class PackageManagerRequirementsHooks { '@version' => $this->composerInspector->getVersion(), '@path' => $this->executableFinder->find('composer'), ]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; } catch (\Throwable $e) { @@ -55,7 +56,7 @@ class PackageManagerRequirementsHooks { 'description' => $this->t('Composer was not found. The error message was: @message', [ '@message' => $message, ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -90,7 +91,7 @@ class PackageManagerRequirementsHooks { $requirements['testing_package_manager'] = [ 'title' => 'Package Manager', 'description' => $this->t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -125,7 +126,7 @@ class PackageManagerRequirementsHooks { $requirements['package_manager_failure_marker'] = [ 'title' => $this->t('Failed Package Manager update detected'), 'description' => $exception->getMessage(), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } diff --git a/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php index 45e0166ed87..aac542e6275 100644 --- a/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php +++ b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\package_manager\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Site\Settings; use Drupal\package_manager\Exception\FailureMarkerExistsException; use Drupal\package_manager\FailureMarker; @@ -24,7 +25,7 @@ class PackageManagerRequirements implements InstallRequirementsInterface { $requirements['testing_package_manager'] = [ 'title' => 'Package Manager', 'description' => t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -41,7 +42,7 @@ class PackageManagerRequirements implements InstallRequirementsInterface { $requirements['package_manager_failure_marker'] = [ 'title' => t('Failed Package Manager update detected'), 'description' => $exception->getMessage(), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } diff --git a/core/modules/package_manager/src/SandboxManagerBase.php b/core/modules/package_manager/src/SandboxManagerBase.php index 4b3c6065432..15836def8f8 100644 --- a/core/modules/package_manager/src/SandboxManagerBase.php +++ b/core/modules/package_manager/src/SandboxManagerBase.php @@ -8,11 +8,13 @@ use Composer\Semver\VersionParser; use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\Utility\Random; use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Site\Settings; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\TempStore\SharedTempStore; use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\Core\Utility\Error; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\Event\CollectPathsToExcludeEvent; use Drupal\package_manager\Event\PostApplyEvent; use Drupal\package_manager\Event\PostCreateEvent; @@ -147,9 +149,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * * Consists of a unique random string and the current class name. * - * @var string[] + * @var string[]|null */ - private $lock; + private ?array $lock = NULL; /** * The shared temp store. @@ -338,6 +340,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $id, static::class, $this->getType(), + $this->isDirectWrite(), ]); $this->claim($id); @@ -351,7 +354,12 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $this->dispatch($event, [$this, 'markAsAvailable']); try { - $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout); + if ($this->isDirectWrite()) { + $this->logger?->info($this->t('Direct-write is enabled. Skipping sandboxing.')); + } + else { + $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout); + } } catch (\Throwable $error) { $this->destroy(); @@ -372,7 +380,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { } /** - * Adds or updates packages in the stage directory. + * Adds or updates packages in the sandbox directory. + * + * If this sandbox manager is running in direct-write mode, the changes will + * be made in the active directory. * * @param string[] $runtime * The packages to add as regular top-level dependencies, in the form @@ -430,8 +441,18 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { // If constraints were changed, update those packages. if ($runtime || $dev) { - $command = array_merge(['update', '--with-all-dependencies', '--optimize-autoloader'], $runtime, $dev); - $do_stage($command); + $do_stage([ + 'update', + // Allow updating top-level dependencies. + '--with-all-dependencies', + // Always optimize the autoloader for better site performance. + '--optimize-autoloader', + // For extra safety and speed, make Composer do only the necessary + // changes to transitive (indirect) dependencies. + '--minimal-changes', + ...$runtime, + ...$dev, + ]); } $this->dispatch(new PostRequireEvent($this, $runtime, $dev)); } @@ -458,6 +479,13 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * a failed commit operation. */ public function apply(?int $timeout = 600): void { + // In direct-write mode, changes are made directly to the running code base, + // so there is nothing to do. + if ($this->isDirectWrite()) { + $this->logger?->info($this->t('Direct-write is enabled. Changes have been made to the running code base.')); + return; + } + $this->checkOwnership(); $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); @@ -556,7 +584,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { // If the stage directory exists, queue it to be automatically cleaned up // later by a queue (which may or may not happen during cron). // @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner - if ($this->sandboxDirectoryExists()) { + if ($this->sandboxDirectoryExists() && !$this->isDirectWrite()) { $this->queueFactory->get('package_manager_cleanup') ->createItem($this->getSandboxDirectory()); } @@ -659,8 +687,14 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { )->render()); } - if ($stored_lock === [$unique_id, static::class, $this->getType()]) { + if (array_slice($stored_lock, 0, 3) === [$unique_id, static::class, $this->getType()]) { $this->lock = $stored_lock; + + if ($this->isDirectWrite()) { + // Bypass a hard-coded set of Composer Stager preconditions that prevent + // the active directory from being modified directly. + DirectWritePreconditionBypass::activate(); + } return $this; } @@ -717,7 +751,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * Returns the path of the directory where changes should be staged. * * @return string - * The absolute path of the directory where changes should be staged. + * The absolute path of the directory where changes should be staged. If + * this sandbox manager is operating in direct-write mode, this will be + * path of the active directory. * * @throws \LogicException * If this method is called before the stage has been created or claimed. @@ -726,6 +762,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { if (!$this->lock) { throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.'); } + + if ($this->isDirectWrite()) { + return $this->pathLocator->getProjectRoot(); + } return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0]; } @@ -848,4 +888,26 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message); } + /** + * Indicates whether the active directory will be changed directly. + * + * This can only happen if direct-write is globally enabled by the + * `package_manager_allow_direct_write` setting, AND this class explicitly + * allows it (by adding the AllowDirectWrite attribute). + * + * @return bool + * TRUE if the sandbox manager is operating in direct-write mode, otherwise + * FALSE. + */ + final public function isDirectWrite(): bool { + // The use of direct-write is stored as part of the lock so that it will + // remain consistent during the sandbox's entire life cycle, even if the + // underlying global settings are changed. + if ($this->lock) { + return $this->lock[3]; + } + $reflector = new \ReflectionClass($this); + return Settings::get('package_manager_allow_direct_write', FALSE) && $reflector->getAttributes(AllowDirectWrite::class); + } + } diff --git a/core/modules/package_manager/src/ValidationResult.php b/core/modules/package_manager/src/ValidationResult.php index be540eb7a73..3c29c2cc013 100644 --- a/core/modules/package_manager/src/ValidationResult.php +++ b/core/modules/package_manager/src/ValidationResult.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Drupal\package_manager; use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\system\SystemManager; use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; /** @@ -22,6 +22,7 @@ final class ValidationResult { * @param int $severity * The severity of the result. Should be one of the * SystemManager::REQUIREMENT_* constants. + * @todo Refactor this to use RequirementSeverity in https://www.drupal.org/i/3525121. * @param \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[] $messages * The result messages. * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary @@ -76,7 +77,7 @@ final class ValidationResult { // All Composer Stager exceptions are translatable. $is_translatable = $throwable instanceof ExceptionInterface; $message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage(); - return new static(SystemManager::REQUIREMENT_ERROR, [$message], $summary, $is_translatable); + return new static(RequirementSeverity::Error->value, [$message], $summary, $is_translatable); } /** @@ -90,7 +91,7 @@ final class ValidationResult { * @return static */ public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): static { - return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary, TRUE); + return new static(RequirementSeverity::Error->value, $messages, $summary, TRUE); } /** @@ -104,7 +105,7 @@ final class ValidationResult { * @return static */ public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): static { - return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary, TRUE); + return new static(RequirementSeverity::Warning->value, $messages, $summary, TRUE); } /** @@ -119,12 +120,12 @@ final class ValidationResult { */ public static function getOverallSeverity(array $results): int { foreach ($results as $result) { - if ($result->severity === SystemManager::REQUIREMENT_ERROR) { - return SystemManager::REQUIREMENT_ERROR; + if ($result->severity === RequirementSeverity::Error->value) { + return RequirementSeverity::Error->value; } } // If there were no errors, then any remaining results must be warnings. - return $results ? SystemManager::REQUIREMENT_WARNING : SystemManager::REQUIREMENT_OK; + return $results ? RequirementSeverity::Warning->value : RequirementSeverity::OK->value; } /** diff --git a/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php index 765fccd20cf..9de2911fb23 100644 --- a/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php +++ b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php @@ -2,12 +2,12 @@ namespace Drupal\package_manager\Validator; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; -use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Event\StatusCheckEvent; -use Drupal\system\SystemManager; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -51,7 +51,7 @@ final class BaseRequirementsFulfilledValidator implements EventSubscriberInterfa // If there are any errors from the validators which ran before this one, // base requirements are not fulfilled. Stop any further validators from // running. - if ($event->getResults(SystemManager::REQUIREMENT_ERROR)) { + if ($event->getResults(RequirementSeverity::Error->value)) { $event->stopPropagation(); } } diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php index ead8740ba84..c63b283b238 100644 --- a/core/modules/package_manager/src/Validator/LockFileValidator.php +++ b/core/modules/package_manager/src/Validator/LockFileValidator.php @@ -111,6 +111,12 @@ final class LockFileValidator implements EventSubscriberInterface { public function validate(SandboxValidationEvent $event): void { $sandbox_manager = $event->sandboxManager; + // If we're going to change the active directory directly, we don't need to + // validate the lock file's consistency, since there is no separate + // sandbox directory to compare against. + if ($sandbox_manager->isDirectWrite()) { + return; + } // Early return if the stage is not already created. if ($event instanceof StatusCheckEvent && $sandbox_manager->isAvailable()) { return; diff --git a/core/modules/package_manager/src/Validator/RsyncValidator.php b/core/modules/package_manager/src/Validator/RsyncValidator.php index 37fe6eb76a5..eeb3f3a8b56 100644 --- a/core/modules/package_manager/src/Validator/RsyncValidator.php +++ b/core/modules/package_manager/src/Validator/RsyncValidator.php @@ -38,6 +38,12 @@ final class RsyncValidator implements EventSubscriberInterface { * The event being handled. */ public function validate(SandboxValidationEvent $event): void { + // If the we are going to change the active directory directly, we don't + // need rsync. + if ($event->sandboxManager->isDirectWrite()) { + return; + } + try { $this->executableFinder->find('rsync'); $rsync_found = TRUE; diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php index be088454061..20194d5c678 100644 --- a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -7,6 +7,7 @@ namespace Drupal\package_manager_test_api; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Url; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\FailureMarker; use Drupal\package_manager\PathLocator; use Drupal\package_manager\SandboxManagerBase; @@ -91,7 +92,7 @@ class ApiController extends ControllerBase { public function finish(string $id): Response { $this->stage->claim($id)->postApply(); $this->stage->destroy(); - return new Response(); + return new Response('Finished'); } /** @@ -142,6 +143,7 @@ class ApiController extends ControllerBase { * * @see \Drupal\package_manager\SandboxManagerBase::claim() */ +#[AllowDirectWrite] final class ControllerSandboxManager extends SandboxManagerBase { /** diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php index ec53f485dfb..bea2c0d4024 100644 --- a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php +++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php @@ -15,9 +15,14 @@ class PackageInstallTest extends TemplateProjectTestBase { /** * Tests installing packages in a stage directory. + * + * @testWith [true] + * [false] */ - public function testPackageInstall(): void { + public function testPackageInstall(bool $allow_direct_write): void { $this->createTestProject('RecommendedProject'); + $allow_direct_write = var_export($allow_direct_write, TRUE); + $this->writeSettings("\n\$settings['package_manager_allow_direct_write'] = $allow_direct_write;"); $this->setReleaseMetadata([ 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php index 16cd486ad75..dcc5b879a2d 100644 --- a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -347,7 +347,7 @@ END; $this->assertDirectoryIsWritable($log); $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name(); if ($this->usesDataProvider()) { - $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName()); + $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', (string) $this->dataName()); } $code .= <<<END \$config['package_manager.settings']['log'] = '$log-package_manager.log'; @@ -441,6 +441,8 @@ END; $requirements['symfony/polyfill-php81'], $requirements['symfony/polyfill-php82'], $requirements['symfony/polyfill-php83'], + // Needed for PHP 8.4 features while PHP 8.3 is the minimum. + $requirements['symfony/polyfill-php84'], ); // If this package requires any Drupal core packages, ensure it allows // any version. @@ -719,6 +721,9 @@ END; $this->serverErrorLog, ); $this->assertSame(200, $session->getStatusCode(), $message); + // Sometimes we get a 200 response after a PHP timeout or OOM error, so we + // also check the page content to ensure it's what we expect. + $this->assertSame('Finished', $session->getPage()->getText()); } /** diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php index 0411978a175..61f922824bd 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -230,7 +230,7 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase { * ["2.5.0", "<default>"] * ["2.5.5", "<default>"] * ["2.5.11", "<default>"] - * ["2.6.0", null] + * ["2.7.0", null] * ["2.2.11", "<default>"] * ["2.2.0-dev", "<default>"] * ["2.3.6", "<default>"] diff --git a/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php new file mode 100644 index 00000000000..3208fddbbf4 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\SandboxEvent; +use Drupal\package_manager\Exception\SandboxEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @covers \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber + * @covers \Drupal\package_manager\SandboxManagerBase::isDirectWrite + * + * @group package_manager + */ +class DirectWriteTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + use StatusCheckTrait; + use StringTranslationTrait; + + /** + * Whether we are in maintenance mode before a require operation. + * + * @var bool|null + * + * @see ::onPreRequire() + */ + private ?bool $preRequireMaintenanceMode = NULL; + + /** + * Whether we are in maintenance mode after a require operation. + * + * @var bool|null + * + * @see ::onPostRequire() + */ + private ?bool $postRequireMaintenanceMode = NULL; + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + // The pre-require and post-require listeners need to run after + // \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber. + PreRequireEvent::class => ['onPreRequire', -10001], + PostRequireEvent::class => ['onPostRequire', 9999], + PreApplyEvent::class => 'assertNotDirectWrite', + ]; + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->container->get(EventDispatcherInterface::class) + ->addSubscriber($this); + } + + /** + * Event listener that asserts the sandbox manager isn't in direct-write mode. + * + * @param \Drupal\package_manager\Event\SandboxEvent $event + * The event being handled. + */ + public function assertNotDirectWrite(SandboxEvent $event): void { + $this->assertFalse($event->sandboxManager->isDirectWrite()); + } + + /** + * Event listener that records the maintenance mode flag on pre-require. + */ + public function onPreRequire(): void { + $this->preRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class) + ->get('system.maintenance_mode'); + } + + /** + * Event listener that records the maintenance mode flag on post-require. + */ + public function onPostRequire(): void { + $this->postRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class) + ->get('system.maintenance_mode'); + } + + /** + * Tests that direct-write does not work if it is globally disabled. + */ + public function testSiteSandboxedIfDirectWriteGloballyDisabled(): void { + // Even if we use a sandbox manager that supports direct write, it should + // not be enabled. + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $logger = new TestLogger(); + $sandbox_manager->setLogger($logger); + $this->assertFalse($sandbox_manager->isDirectWrite()); + $sandbox_manager->create(); + $this->assertTrue($sandbox_manager->sandboxDirectoryExists()); + $this->assertNotSame( + $this->container->get(PathLocator::class)->getProjectRoot(), + $sandbox_manager->getSandboxDirectory(), + ); + $this->assertFalse($logger->hasRecords('info')); + } + + /** + * Tests direct-write mode when globally enabled. + */ + public function testSiteNotSandboxedIfDirectWriteGloballyEnabled(): void { + $mock_beginner = $this->createMock(BeginnerInterface::class); + $mock_beginner->expects($this->never()) + ->method('begin') + ->withAnyParameters(); + $this->container->set(BeginnerInterface::class, $mock_beginner); + + $mock_committer = $this->createMock(CommitterInterface::class); + $mock_committer->expects($this->never()) + ->method('commit') + ->withAnyParameters(); + $this->container->set(CommitterInterface::class, $mock_committer); + + $this->setSetting('package_manager_allow_direct_write', TRUE); + + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $logger = new TestLogger(); + $sandbox_manager->setLogger($logger); + $this->assertTrue($sandbox_manager->isDirectWrite()); + + // A status check should flag a warning about running in direct-write mode. + $expected_results = [ + ValidationResult::createWarning([ + $this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'), + ]), + ]; + $actual_results = $this->runStatusCheck($sandbox_manager); + $this->assertValidationResultsEqual($expected_results, $actual_results); + + $sandbox_manager->create(); + // In direct-write mode, the active and sandbox directories are the same. + $this->assertTrue($sandbox_manager->sandboxDirectoryExists()); + $this->assertSame( + $this->container->get(PathLocator::class)->getProjectRoot(), + $sandbox_manager->getSandboxDirectory(), + ); + + // Do a require operation so we can assert that we are kicked into, and out + // of, maintenance mode. + $sandbox_manager->require(['ext-json:*']); + $this->assertTrue($this->preRequireMaintenanceMode); + $this->assertFalse($this->postRequireMaintenanceMode); + + $sandbox_manager->apply(); + $sandbox_manager->postApply(); + // Destroying the sandbox should not populate the clean-up queue. + $sandbox_manager->destroy(); + /** @var \Drupal\Core\Queue\QueueInterface $queue */ + $queue = $this->container->get(QueueFactory::class) + ->get('package_manager_cleanup'); + $this->assertSame(0, $queue->numberOfItems()); + + $records = $logger->recordsByLevel['info']; + $this->assertCount(2, $records); + $this->assertSame('Direct-write is enabled. Skipping sandboxing.', (string) $records[0]['message']); + $this->assertSame('Direct-write is enabled. Changes have been made to the running code base.', (string) $records[1]['message']); + + // A sandbox manager that doesn't support direct-write should not be + // influenced by the setting. + $this->assertFalse($this->createStage()->isDirectWrite()); + } + + /** + * Tests that pre-require errors prevent maintenance mode during direct-write. + */ + public function testMaintenanceModeNotEnteredIfErrorOnPreRequire(): void { + $this->setSetting('package_manager_allow_direct_write', TRUE); + // Sanity check: we shouldn't be in maintenance mode to begin with. + $state = $this->container->get(StateInterface::class); + $this->assertEmpty($state->get('system.maintenance_mode')); + + // Set up an event subscriber which will flag an error. + $this->container->get(EventDispatcherInterface::class) + ->addListener(PreRequireEvent::class, function (PreRequireEvent $event): void { + $event->addError([ + $this->t('Maintenance mode should not happen.'), + ]); + }); + + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $sandbox_manager->create(); + try { + $sandbox_manager->require(['ext-json:*']); + $this->fail('Expected an exception to be thrown on pre-require.'); + } + catch (SandboxEventException $e) { + $this->assertSame("Maintenance mode should not happen.\n", $e->getMessage()); + // We should never have entered maintenance mode. + $this->assertFalse($this->preRequireMaintenanceMode); + // Sanity check: the post-require event should never have been dispatched. + $this->assertNull($this->postRequireMaintenanceMode); + } + } + + /** + * Tests that the sandbox's direct-write status is part of its locking info. + */ + public function testDirectWriteFlagIsLocked(): void { + $this->setSetting('package_manager_allow_direct_write', TRUE); + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $this->assertTrue($sandbox_manager->isDirectWrite()); + $sandbox_manager->create(); + $this->setSetting('package_manager_allow_direct_write', FALSE); + $this->assertTrue($sandbox_manager->isDirectWrite()); + // Only once the sandbox is destroyed should the sandbox manager reflect the + // changed setting. + $sandbox_manager->destroy(); + $this->assertFalse($sandbox_manager->isDirectWrite()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 3c2e32b1e7c..5bcc43a8138 100644 --- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -12,6 +12,7 @@ use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Site\Settings; use Drupal\fixture_manipulator\StageFixtureManipulator; use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Exception\SandboxEventException; @@ -173,11 +174,15 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { /** * Creates a stage object for testing purposes. * + * @param class-string $class + * (optional) The class of the sandbox manager to create. Defaults to + * \Drupal\Tests\package_manager\Kernel\TestSandboxManager. + * * @return \Drupal\Tests\package_manager\Kernel\TestSandboxManager * A stage object, with test-only modifications. */ - protected function createStage(): TestSandboxManager { - return new TestSandboxManager( + protected function createStage(?string $class = TestSandboxManager::class): TestSandboxManager { + return new $class( $this->container->get(PathLocator::class), $this->container->get(BeginnerInterface::class), $this->container->get(StagerInterface::class), @@ -476,6 +481,19 @@ class TestSandboxManager extends SandboxManagerBase { } /** + * Defines a test-only sandbox manager that allows direct-write. + */ +#[AllowDirectWrite] +class TestDirectWriteSandboxManager extends TestSandboxManager { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager:test_direct_write'; + +} + +/** * A test version of the disk space validator to bypass system-level functions. */ class TestDiskSpaceValidator extends DiskSpaceValidator { diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php index 188c654929d..02be8f298aa 100644 --- a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -76,4 +76,13 @@ class RsyncValidatorTest extends PackageManagerKernelTestBase { $this->assertResults([$result], PreCreateEvent::class); } + /** + * Tests that the presence of rsync is not checked in direct-write mode. + */ + public function testRsyncNotNeededForDirectWrite(): void { + $this->executableFinder->find('rsync')->shouldNotBeCalled(); + $this->setSetting('package_manager_allow_direct_write', TRUE); + $this->createStage(TestDirectWriteSandboxManager::class)->create(); + } + } diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php index 90348cdfdd3..2e9a0977fa3 100644 --- a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -13,7 +13,6 @@ use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator * @group #slow * @group package_manager - * @group #slow * @internal */ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php index 2b46e1de9c8..00366b8c318 100644 --- a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php +++ b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\Tests\package_manager\Unit; -use Drupal\package_manager\ValidationResult; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\system\SystemManager; +use Drupal\package_manager\ValidationResult; use Drupal\Tests\UnitTestCase; /** @@ -25,7 +25,7 @@ class ValidationResultTest extends UnitTestCase { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString, DrupalPractice.Objects.GlobalFunction $summary = $summary ? t($summary) : NULL; $result = ValidationResult::createWarning($messages, $summary); - $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING); + $this->assertResultValid($result, $messages, $summary, RequirementSeverity::Warning->value); } /** @@ -39,16 +39,17 @@ class ValidationResultTest extends UnitTestCase { ValidationResult::createWarning([t('Moo!')]), // phpcs:enable DrupalPractice.Objects.GlobalFunction ]; - $this->assertSame(SystemManager::REQUIREMENT_ERROR, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::Error->value, ValidationResult::getOverallSeverity($results)); // If there are no results, but no errors, the results should be counted as // a warning. array_shift($results); - $this->assertSame(SystemManager::REQUIREMENT_WARNING, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::Warning->value, ValidationResult::getOverallSeverity($results)); - // If there are just plain no results, we should get REQUIREMENT_OK. + // If there are just plain no results, we should get + // RequirementSeverity::OK. array_shift($results); - $this->assertSame(SystemManager::REQUIREMENT_OK, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::OK->value, ValidationResult::getOverallSeverity($results)); } /** @@ -60,7 +61,7 @@ class ValidationResultTest extends UnitTestCase { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString, DrupalPractice.Objects.GlobalFunction $summary = $summary ? t($summary) : NULL; $result = ValidationResult::createError($messages, $summary); - $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR); + $this->assertResultValid($result, $messages, $summary, RequirementSeverity::Error->value); } /** diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php index 41f2e8b8e4f..da6d22bfb05 100644 --- a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php +++ b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php @@ -155,7 +155,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', @@ -195,7 +194,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', diff --git a/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php b/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php index d5cc9759ab1..be5d811fe54 100644 --- a/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php +++ b/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php @@ -13,6 +13,7 @@ use Drupal\Tests\Traits\Core\PathAliasTestTrait; /** * URL alias migration. * + * @group #slow * @group migrate_drupal_6 */ class MigrateUrlAliasTest extends MigrateDrupal6TestBase { diff --git a/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php index 66b8e2dfea0..65fa78a5e71 100644 --- a/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php +++ b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php @@ -3,6 +3,7 @@ namespace Drupal\pgsql\Hook; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Hook\Attribute\Hook; @@ -29,7 +30,7 @@ class PgsqlRequirementsHooks { // Set the requirement just for postgres. if ($connection->driver() == 'pgsql') { $requirements['pgsql_extension_pg_trgm'] = [ - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'title' => $this->t('PostgreSQL pg_trgm extension'), 'value' => $this->t('Available'), 'description' => $this->t('The pg_trgm PostgreSQL extension is present.'), @@ -37,7 +38,7 @@ class PgsqlRequirementsHooks { // If the extension is not available, set the requirement error. if (!$connection->schema()->extensionExists('pg_trgm')) { - $requirements['pgsql_extension_pg_trgm']['severity'] = REQUIREMENT_ERROR; + $requirements['pgsql_extension_pg_trgm']['severity'] = RequirementSeverity::Error; $requirements['pgsql_extension_pg_trgm']['value'] = $this->t('Not created'); $requirements['pgsql_extension_pg_trgm']['description'] = $this->t('The <a href=":pg_trgm">pg_trgm</a> PostgreSQL extension is not present. The extension is required by Drupal to improve performance when using PostgreSQL. See <a href=":requirements">Drupal database server requirements</a> for more information.', [ ':pg_trgm' => 'https://www.postgresql.org/docs/current/pgtrgm.html', diff --git a/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php index a2f7771575e..ab4b936dcba 100644 --- a/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php +++ b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\pgsql\Install\Requirements; use Drupal\Core\Database\Database; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the pgsql module. @@ -24,7 +25,7 @@ class PgsqlRequirements implements InstallRequirementsInterface { // Set the requirement just for postgres. if ($connection->driver() == 'pgsql') { $requirements['pgsql_extension_pg_trgm'] = [ - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'title' => t('PostgreSQL pg_trgm extension'), 'value' => t('Available'), 'description' => t('The pg_trgm PostgreSQL extension is present.'), @@ -32,7 +33,7 @@ class PgsqlRequirements implements InstallRequirementsInterface { // If the extension is not available, set the requirement error. if (!$connection->schema()->extensionExists('pg_trgm')) { - $requirements['pgsql_extension_pg_trgm']['severity'] = REQUIREMENT_ERROR; + $requirements['pgsql_extension_pg_trgm']['severity'] = RequirementSeverity::Error; $requirements['pgsql_extension_pg_trgm']['value'] = t('Not created'); $requirements['pgsql_extension_pg_trgm']['description'] = t('The <a href=":pg_trgm">pg_trgm</a> PostgreSQL extension is not present. The extension is required by Drupal to improve performance when using PostgreSQL. See <a href=":requirements">Drupal database server requirements</a> for more information.', [ ':pg_trgm' => 'https://www.postgresql.org/docs/current/pgtrgm.html', diff --git a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php index 5d862a86421..5c5e6be5838 100644 --- a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php +++ b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php @@ -328,7 +328,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase { if (!$empty_styles) { $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.medium'); $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.thumbnail'); - $this->assertSession()->responseContains('type="image/webp"'); + $this->assertSession()->responseContains('type="image/avif"'); } $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.large'); @@ -504,7 +504,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase { // Assert the picture tag has source tags that include dimensions. $this->drupalGet('node/' . $nid); - $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/webp" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/webp" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/'); + $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/avif" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/avif" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/'); } /** diff --git a/core/modules/search/src/Hook/SearchRequirements.php b/core/modules/search/src/Hook/SearchRequirements.php index 4fd79e64031..14e7dcb1649 100644 --- a/core/modules/search/src/Hook/SearchRequirements.php +++ b/core/modules/search/src/Hook/SearchRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\search\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\search\SearchPageRepositoryInterface; @@ -42,7 +43,7 @@ class SearchRequirements { $requirements['search_status'] = [ 'title' => $this->t('Search index progress'), 'value' => $this->t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; return $requirements; } diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php index 8158de67c50..46bea0731d0 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php @@ -415,7 +415,7 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn public function prepareStatement(string $query, array $options, bool $allow_row_count = FALSE): StatementInterface { assert(!isset($options['return']), 'Passing "return" option to prepareStatement() has no effect. See https://www.drupal.org/node/3185520'); if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } try { diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php index 1c7378a0173..c3060a57234 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php @@ -87,7 +87,7 @@ class Statement extends StatementPrefetchIterator implements StatementInterface */ public function execute($args = [], $options = []) { if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } try { diff --git a/core/modules/system/css/components/position-container.module.css b/core/modules/system/css/components/position-container.module.css deleted file mode 100644 index ae209f3aa61..00000000000 --- a/core/modules/system/css/components/position-container.module.css +++ /dev/null @@ -1,8 +0,0 @@ -/* - * @file - * Contain positioned elements. - */ - -.position-container { - position: relative; -} diff --git a/core/modules/system/src/Controller/DbUpdateController.php b/core/modules/system/src/Controller/DbUpdateController.php index 131b6a075d5..2f5ae051204 100644 --- a/core/modules/system/src/Controller/DbUpdateController.php +++ b/core/modules/system/src/Controller/DbUpdateController.php @@ -7,6 +7,7 @@ use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; use Drupal\Core\Render\BareHtmlPageRendererInterface; use Drupal\Core\Session\AccountInterface; @@ -166,8 +167,8 @@ class DbUpdateController extends ControllerBase { $regions = []; $requirements = update_check_requirements(); - $severity = drupal_requirements_severity($requirements); - if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && !$request->getSession()->has('update_ignore_warnings'))) { + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); + if ($severity === RequirementSeverity::Error || ($severity === RequirementSeverity::Warning && !$request->getSession()->has('update_ignore_warnings'))) { $regions['sidebar_first'] = $this->updateTasksList('requirements'); $output = $this->requirements($severity, $requirements, $request); } @@ -543,7 +544,7 @@ class DbUpdateController extends ControllerBase { * A render array. */ public function requirements($severity, array $requirements, Request $request) { - $options = $severity == REQUIREMENT_WARNING ? ['continue' => 1] : []; + $options = $severity === RequirementSeverity::Warning ? ['continue' => 1] : []; // @todo Revisit once https://www.drupal.org/node/2548095 is in. Something // like Url::fromRoute('system.db_update')->setOptions() should then be // possible. diff --git a/core/modules/system/src/Element/StatusReportPage.php b/core/modules/system/src/Element/StatusReportPage.php index 90a878831ea..2d6494f2fe3 100644 --- a/core/modules/system/src/Element/StatusReportPage.php +++ b/core/modules/system/src/Element/StatusReportPage.php @@ -2,9 +2,9 @@ namespace Drupal\system\Element; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Render\Attribute\RenderElement; use Drupal\Core\Render\Element\RenderElementBase; -use Drupal\Core\Render\Element\StatusReport; use Drupal\Core\StringTranslation\PluralTranslatableMarkup; /** @@ -37,6 +37,7 @@ class StatusReportPage extends RenderElementBase { '#theme' => 'status_report_general_info', ]; // Loop through requirements and pull out items. + RequirementSeverity::convertLegacyIntSeveritiesToEnums($element['#requirements'], __METHOD__); foreach ($element['#requirements'] as $key => $requirement) { switch ($key) { case 'cron': @@ -59,10 +60,10 @@ class StatusReportPage extends RenderElementBase { case 'php': case 'php_memory_limit': $element['#general_info']['#' . $key] = $requirement; - if (isset($requirement['severity']) && $requirement['severity'] < REQUIREMENT_WARNING) { - if (empty($requirement['severity']) || $requirement['severity'] == REQUIREMENT_OK) { - unset($element['#requirements'][$key]); - } + if (isset($requirement['severity']) && + in_array($requirement['severity'], [RequirementSeverity::Info, RequirementSeverity::OK], TRUE) + ) { + unset($element['#requirements'][$key]); } break; } @@ -94,18 +95,18 @@ class StatusReportPage extends RenderElementBase { ], ]; - $severities = StatusReport::getSeverities(); + RequirementSeverity::convertLegacyIntSeveritiesToEnums($element['#requirements'], __METHOD__); foreach ($element['#requirements'] as $key => &$requirement) { - $severity = $severities[REQUIREMENT_INFO]; + $severity = RequirementSeverity::Info; if (isset($requirement['severity'])) { - $severity = $severities[(int) $requirement['severity']]; + $severity = $requirement['severity']; } elseif (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'install') { - $severity = $severities[REQUIREMENT_OK]; + $severity = RequirementSeverity::OK; } - if (isset($counters[$severity['status']])) { - $counters[$severity['status']]['amount']++; + if (isset($counters[$severity->status()])) { + $counters[$severity->status()]['amount']++; } } diff --git a/core/modules/system/src/Hook/SystemHooks.php b/core/modules/system/src/Hook/SystemHooks.php index ae3e8d71074..86d18164623 100644 --- a/core/modules/system/src/Hook/SystemHooks.php +++ b/core/modules/system/src/Hook/SystemHooks.php @@ -276,7 +276,11 @@ class SystemHooks { // before doing so. Also add the loaded libraries to ajaxPageState. /** @var \Drupal\Core\Asset\LibraryDependencyResolver $library_dependency_resolver */ $library_dependency_resolver = \Drupal::service('library.dependency_resolver'); - if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()))) { + $loaded_libraries = []; + if (!isset($settings['ajaxPageState'])) { + $loaded_libraries = $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()); + } + if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $loaded_libraries) || in_array('core/drupal.htmx', $loaded_libraries)) { if (!defined('MAINTENANCE_MODE')) { // The theme token is only validated when the theme requested is not the // default, so don't generate it unless necessary. diff --git a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php index 670fbc06cf7..2ce434e0fd3 100644 --- a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php +++ b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php @@ -4,6 +4,7 @@ namespace Drupal\system\Plugin\ImageToolkit; use Drupal\Component\Utility\Color; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileExists; use Drupal\Core\File\FileSystemInterface; @@ -439,9 +440,6 @@ class GDToolkit extends ImageToolkitBase { IMG_AVIF => 'AVIF', ]; $supported_formats = array_filter($check_formats, fn($type) => imagetypes() & $type, ARRAY_FILTER_USE_KEY); - if (isset($supported_formats[IMG_AVIF]) && !$this->checkAvifSupport()) { - unset($supported_formats[IMG_AVIF]); - } $unsupported_formats = array_diff_key($check_formats, $supported_formats); $descriptions = []; @@ -454,7 +452,7 @@ class GDToolkit extends ImageToolkitBase { ); } if ($unsupported_formats) { - $requirements['version']['severity'] = REQUIREMENT_WARNING; + $requirements['version']['severity'] = RequirementSeverity::Warning; $unsupported = $this->formatPlural( count($unsupported_formats), 'Unsupported image file format: %formats.', @@ -475,7 +473,7 @@ class GDToolkit extends ImageToolkitBase { // Check for filter and rotate support. if (!function_exists('imagefilter') || !function_exists('imagerotate')) { - $requirements['version']['severity'] = REQUIREMENT_WARNING; + $requirements['version']['severity'] = RequirementSeverity::Warning; $descriptions[] = $this->t('The GD Library for PHP is enabled, but was compiled without support for functions used by the rotate and desaturate effects. It was probably compiled using the official GD libraries from the <a href="https://libgd.github.io/">gdLibrary site</a> instead of the GD library bundled with PHP. You should recompile PHP --with-gd using the bundled GD library. See <a href="https://www.php.net/manual/book.image.php">the PHP manual</a>.'); } @@ -556,7 +554,7 @@ class GDToolkit extends ImageToolkitBase { * @return bool * TRUE if AVIF is fully supported, FALSE otherwise. */ - protected function checkAvifSupport(): bool { + protected static function checkAvifSupport(): bool { static $supported = NULL; if ($supported !== NULL) { @@ -564,7 +562,7 @@ class GDToolkit extends ImageToolkitBase { } $tempFile = fopen('php://memory', 'r+'); - $supported = imageavif(imagecreatetruecolor(1, 1), $tempFile, 0, 10) && fstat($tempFile)['size'] > 0; + $supported = function_exists('imageavif') && imageavif(imagecreatetruecolor(1, 1), $tempFile, 0, 10) && fstat($tempFile)['size'] > 0; fclose($tempFile); return $supported; @@ -578,13 +576,16 @@ class GDToolkit extends ImageToolkitBase { * IMAGETYPE_* constant (e.g. IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.). */ protected static function supportedTypes() { - return [ + $types = [ IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_WEBP, - IMAGETYPE_AVIF, ]; + if (static::checkAvifSupport()) { + $types[] = IMAGETYPE_AVIF; + } + return $types; } } diff --git a/core/modules/system/src/SystemManager.php b/core/modules/system/src/SystemManager.php index 5534e70147b..43a53fe0542 100644 --- a/core/modules/system/src/SystemManager.php +++ b/core/modules/system/src/SystemManager.php @@ -2,11 +2,12 @@ namespace Drupal\system; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Menu\MenuActiveTrailInterface; -use Drupal\Core\Menu\MenuLinkTreeInterface; use Drupal\Core\Menu\MenuLinkInterface; +use Drupal\Core\Menu\MenuLinkTreeInterface; use Drupal\Core\Menu\MenuTreeParameters; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Symfony\Component\HttpFoundation\RequestStack; @@ -54,16 +55,31 @@ class SystemManager { /** * Requirement severity -- Requirement successfully met. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::OK instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_OK = 0; /** * Requirement severity -- Warning condition; proceed but flag warning. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Warning instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_WARNING = 1; /** * Requirement severity -- Error condition; abort installation. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Error instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_ERROR = 2; @@ -94,7 +110,7 @@ class SystemManager { */ public function checkRequirements() { $requirements = $this->listRequirements(); - return $this->getMaxSeverity($requirements) == static::REQUIREMENT_ERROR; + return RequirementSeverity::maxSeverityFromRequirements($requirements) === RequirementSeverity::Error; } /** @@ -136,15 +152,16 @@ class SystemManager { * * @return int * The highest severity in the array. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::getMaxSeverity() + * instead. + * + * @see https://www.drupal.org/node/3410939 */ public function getMaxSeverity(&$requirements) { - $severity = static::REQUIREMENT_OK; - foreach ($requirements as $requirement) { - if (isset($requirement['severity'])) { - $severity = max($severity, $requirement['severity']); - } - } - return $severity; + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use ' . RequirementSeverity::class . '::maxSeverityFromRequirements() instead. See https://www.drupal.org/node/3410939', \E_USER_DEPRECATED); + return RequirementSeverity::maxSeverityFromRequirements($requirements)->value; } /** diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 9b8c25c157e..431651d08e2 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -14,9 +14,9 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Database; use Drupal\Core\DrupalKernel; use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Link; -use Drupal\Core\Utility\PhpRequirements; use Drupal\Core\Render\Markup; use Drupal\Core\Site\Settings; use Drupal\Core\StreamWrapper\PrivateStream; @@ -27,6 +27,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Update\EquivalentUpdate; use Drupal\Core\Url; use Drupal\Core\Utility\Error; +use Drupal\Core\Utility\PhpRequirements; use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\HttpFoundation\Request; @@ -83,7 +84,7 @@ function system_requirements($phase): array { $requirements['drupal'] = [ 'title' => t('Drupal'), 'value' => \Drupal::VERSION, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'weight' => -10, ]; @@ -99,7 +100,7 @@ function system_requirements($phase): array { '%profile' => $profile, '%version' => !empty($info['version']) ? '-' . $info['version'] : '', ]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'weight' => -9, ]; } @@ -129,7 +130,7 @@ function system_requirements($phase): array { $requirements['experimental_modules'] = [ 'title' => t('Experimental modules installed'), 'value' => t('Experimental modules found: %module_list. <a href=":url">Experimental modules</a> are provided for testing purposes only. Use at your own risk.', ['%module_list' => implode(', ', $experimental_modules), ':url' => 'https://www.drupal.org/core/experimental']), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } // Warn if any deprecated modules are installed. @@ -142,7 +143,7 @@ function system_requirements($phase): array { 'value' => t('Deprecated modules found: %module_list.', [ '%module_list' => Markup::create(implode(', ', $deprecated_modules_link_list)), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -170,7 +171,7 @@ function system_requirements($phase): array { $requirements['experimental_themes'] = [ 'title' => t('Experimental themes installed'), 'value' => t('Experimental themes found: %theme_list. Experimental themes are provided for testing purposes only. Use at your own risk.', ['%theme_list' => implode(', ', $experimental_themes)]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -185,7 +186,7 @@ function system_requirements($phase): array { 'value' => t('Deprecated themes found: %theme_list.', [ '%theme_list' => Markup::create(implode(', ', $deprecated_themes_link_list)), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -200,7 +201,7 @@ function system_requirements($phase): array { '%extensions' => Markup::create(implode(', ', $obsolete_extensions_link_list)), ':uninstall_url' => Url::fromRoute('system.modules_uninstall')->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } _system_advisories_requirements($requirements); @@ -264,7 +265,7 @@ function system_requirements($phase): array { $requirements['apache_version'] = [ 'title' => t('Apache version'), 'value' => $apache_version_string, - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Due to the settings for ServerTokens in httpd.conf, it is impossible to accurately determine the version of Apache running on this server. The reported value is @reported, to run Drupal without mod_rewrite, a minimum version of 2.2.16 is needed.', ['@reported' => $apache_version_string]), ]; } @@ -273,7 +274,7 @@ function system_requirements($phase): array { $requirements['Apache version'] = [ 'title' => t('Apache version'), 'value' => $apache_version_string, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The minimum version of Apache needed to run Drupal without mod_rewrite enabled is 2.2.16. See the <a href=":link">enabling clean URLs</a> page for more information on mod_rewrite.', [':link' => 'https://www.drupal.org/docs/8/clean-urls-in-drupal-8']), ]; } @@ -282,7 +283,7 @@ function system_requirements($phase): array { $requirements['rewrite_module'] = [ 'title' => t('Clean URLs'), 'value' => t('Disabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Your server is capable of using clean URLs, but it is not enabled. Using clean URLs gives an improved user experience and is recommended. <a href=":link">Enable clean URLs</a>', [':link' => 'https://www.drupal.org/docs/8/clean-urls-in-drupal-8']), ]; } @@ -319,19 +320,19 @@ function system_requirements($phase): array { // safe to continue with the requirements check, and should always be an // error. if (version_compare($phpversion, \Drupal::MINIMUM_PHP) < 0) { - $requirements['php']['severity'] = REQUIREMENT_ERROR; + $requirements['php']['severity'] = RequirementSeverity::Error; return $requirements; } // Otherwise, the message should be an error at runtime, and a warning // during installation or update. - $requirements['php']['severity'] = ($phase === 'runtime') ? REQUIREMENT_ERROR : REQUIREMENT_WARNING; + $requirements['php']['severity'] = ($phase === 'runtime') ? RequirementSeverity::Error : RequirementSeverity::Warning; } // For PHP versions that are still supported but no longer recommended, // inform users of what's recommended, allowing them to take action before it // becomes urgent. elseif ($phase === 'runtime' && version_compare($phpversion, \Drupal::RECOMMENDED_PHP) < 0) { $requirements['php']['description'] = t('It is recommended to upgrade to PHP version %recommended or higher for the best ongoing support. See <a href="http://php.net/supported-versions.php">PHP\'s version support documentation</a> and the <a href=":php_requirements">Drupal PHP requirements</a> page for more information.', ['%recommended' => \Drupal::RECOMMENDED_PHP, ':php_requirements' => 'https://www.drupal.org/docs/system-requirements/php-requirements']); - $requirements['php']['severity'] = REQUIREMENT_INFO; + $requirements['php']['severity'] = RequirementSeverity::Info; } // Test for PHP extensions. @@ -381,7 +382,7 @@ function system_requirements($phase): array { ]; $requirements['php_extensions']['value'] = t('Disabled'); - $requirements['php_extensions']['severity'] = REQUIREMENT_ERROR; + $requirements['php_extensions']['severity'] = RequirementSeverity::Error; $requirements['php_extensions']['description'] = $description; } else { @@ -393,7 +394,7 @@ function system_requirements($phase): array { if (!OpCodeCache::isEnabled()) { $requirements['php_opcache'] = [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('PHP OPcode caching can improve your site\'s performance considerably. It is <strong>highly recommended</strong> to have <a href="http://php.net/manual/opcache.installation.php" target="_blank">OPcache</a> installed on your server.'), ]; } @@ -413,7 +414,7 @@ function system_requirements($phase): array { $apcu_recommended_size = '32 MB'; $requirements['php_apcu_enabled']['value'] = t('Enabled (@size)', ['@size' => $apcu_actual_size]); if (Bytes::toNumber(ini_get('apc.shm_size')) * ini_get('apc.shm_segments') < Bytes::toNumber($apcu_recommended_size)) { - $requirements['php_apcu_enabled']['severity'] = REQUIREMENT_WARNING; + $requirements['php_apcu_enabled']['severity'] = RequirementSeverity::Warning; $requirements['php_apcu_enabled']['description'] = t('Depending on your configuration, Drupal can run with a @apcu_size APCu limit. However, a @apcu_default_size APCu limit (the default) or above is recommended, especially if your site uses additional custom or contributed modules.', [ '@apcu_size' => $apcu_actual_size, '@apcu_default_size' => $apcu_recommended_size, @@ -422,19 +423,19 @@ function system_requirements($phase): array { else { $memory_available = $memory_info['avail_mem'] / ($memory_info['seg_size'] * $memory_info['num_seg']); if ($memory_available < 0.1) { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_ERROR; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::Error; $requirements['php_apcu_available']['description'] = t('APCu is using over 90% of its allotted memory (@apcu_actual_size). To improve APCu performance, consider increasing this limit.', [ '@apcu_actual_size' => $apcu_actual_size, ]); } elseif ($memory_available < 0.25) { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_WARNING; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::Warning; $requirements['php_apcu_available']['description'] = t('APCu is using over 75% of its allotted memory (@apcu_actual_size). To improve APCu performance, consider increasing this limit.', [ '@apcu_actual_size' => $apcu_actual_size, ]); } else { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_OK; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::OK; } $requirements['php_apcu_available']['value'] = t('Memory available: @available.', [ '@available' => ByteSizeMarkup::create($memory_info['avail_mem']), @@ -444,7 +445,7 @@ function system_requirements($phase): array { else { $requirements['php_apcu_enabled'] += [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => t('PHP APCu caching can improve your site\'s performance considerably. It is <strong>highly recommended</strong> to have <a href="https://www.php.net/manual/apcu.installation.php" target="_blank">APCu</a> installed on your server.'), ]; } @@ -484,7 +485,7 @@ function system_requirements($phase): array { $requirements['php_random_bytes']['description'] = t('Drupal is unable to generate highly randomized numbers, which means certain security features like password reset URLs are not as secure as they should be. Instead, only a slow, less-secure fallback generator is available. See the <a href=":drupal-php">system requirements</a> page for more information. %exception_message', $args); } $requirements['php_random_bytes']['value'] = t('Less secure'); - $requirements['php_random_bytes']['severity'] = REQUIREMENT_ERROR; + $requirements['php_random_bytes']['severity'] = RequirementSeverity::Error; } } @@ -493,7 +494,7 @@ function system_requirements($phase): array { $requirements['output_buffering'] = [ 'title' => t('Output Buffering'), 'error_value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('<a href="https://www.php.net/manual/en/function.ob-start.php">Output buffering</a> is not enabled. This may degrade Drupal\'s performance. You can enable output buffering by default <a href="https://www.php.net/manual/en/outcontrol.configuration.php#ini.output-buffering">in your PHP settings</a>.'), ]; } @@ -532,7 +533,7 @@ function system_requirements($phase): array { if (!$database_ok) { $requirements['database_extensions']['value'] = t('Disabled'); - $requirements['database_extensions']['severity'] = REQUIREMENT_ERROR; + $requirements['database_extensions']['severity'] = RequirementSeverity::Error; $requirements['database_extensions']['description'] = $pdo_message; } else { @@ -563,7 +564,7 @@ function system_requirements($phase): array { // Use the comma-list style to display a single error without bullets. '#context' => ['list_style' => $error_count === 1 ? 'comma-list' : ''], ]; - $requirements['database_system_version']['severity'] = REQUIREMENT_ERROR; + $requirements['database_system_version']['severity'] = RequirementSeverity::Error; $requirements['database_system_version']['description'] = $error_message; } } @@ -572,14 +573,14 @@ function system_requirements($phase): array { // Test database JSON support. $requirements['database_support_json'] = [ 'title' => t('Database support for JSON'), - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'value' => t('Available'), 'description' => t('Drupal requires databases that support JSON storage.'), ]; if (!Database::getConnection()->hasJson()) { $requirements['database_support_json']['value'] = t('Not available'); - $requirements['database_support_json']['severity'] = REQUIREMENT_ERROR; + $requirements['database_support_json']['severity'] = RequirementSeverity::Error; } } @@ -623,7 +624,7 @@ function system_requirements($phase): array { ]; $requirements['php_memory_limit']['description'] = $description; - $requirements['php_memory_limit']['severity'] = REQUIREMENT_WARNING; + $requirements['php_memory_limit']['severity'] = RequirementSeverity::Warning; } } @@ -645,12 +646,12 @@ function system_requirements($phase): array { $error_value = t('Protection disabled'); // If permissions hardening is disabled, then only show a warning for a // writable file, as a reminder, rather than an error. - $file_protection_severity = REQUIREMENT_WARNING; + $file_protection_severity = RequirementSeverity::Warning; } else { $error_value = t('Not protected'); // In normal operation, writable files or directories are an error. - $file_protection_severity = REQUIREMENT_ERROR; + $file_protection_severity = RequirementSeverity::Error; if (!drupal_verify_install_file($site_path, FILE_NOT_WRITABLE, 'dir')) { $conf_errors[] = t("The directory %file is not protected from modifications and poses a security risk. You must change the directory's permissions to be non-writable.", ['%file' => $site_path]); } @@ -709,7 +710,7 @@ function system_requirements($phase): array { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString 'title' => new TranslatableMarkup($protected_dir->getTitle()), 'value' => t('Not fully protected'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('See <a href=":url">@url</a> for information about the recommended .htaccess file which should be added to the %directory directory to help protect against arbitrary code execution.', [':url' => $url, '@url' => $url, '%directory' => $protected_dir->getPath()]), ]; } @@ -731,13 +732,13 @@ function system_requirements($phase): array { } // Determine severity based on time since cron last ran. - $severity = REQUIREMENT_INFO; + $severity = RequirementSeverity::Info; $request_time = \Drupal::time()->getRequestTime(); if ($request_time - $cron_last > $threshold_error) { - $severity = REQUIREMENT_ERROR; + $severity = RequirementSeverity::Error; } elseif ($request_time - $cron_last > $threshold_warning) { - $severity = REQUIREMENT_WARNING; + $severity = RequirementSeverity::Warning; } // Set summary and description based on values determined above. @@ -748,7 +749,7 @@ function system_requirements($phase): array { 'severity' => $severity, 'value' => $summary, ]; - if ($severity != REQUIREMENT_INFO) { + if ($severity != RequirementSeverity::Info) { $requirements['cron']['description'][] = [ [ '#markup' => t('Cron has not run recently.'), @@ -833,7 +834,7 @@ function system_requirements($phase): array { $requirements['config sync directory'] = [ 'title' => t('Configuration sync directory'), 'description' => $description, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -842,7 +843,7 @@ function system_requirements($phase): array { 'title' => t('Configuration sync directory'), 'value' => t('Not present'), 'description' => t("Your %file file must define the %setting setting as a string containing the directory in which configuration files can be found.", ['%file' => $site_path . '/settings.php', '%setting' => "\$settings['config_sync_directory']"]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -891,7 +892,7 @@ function system_requirements($phase): array { ], ]; $requirements['file system']['description'] = $description; - $requirements['file system']['severity'] = REQUIREMENT_ERROR; + $requirements['file system']['severity'] = RequirementSeverity::Error; } } else { @@ -937,7 +938,7 @@ function system_requirements($phase): array { } if ($has_pending_updates) { - $requirements['update']['severity'] = REQUIREMENT_ERROR; + $requirements['update']['severity'] = RequirementSeverity::Error; $requirements['update']['value'] = t('Out of date'); $requirements['update']['description'] = t('Some modules have database schema updates to install. You should run the <a href=":update">database update script</a> immediately.', [':update' => Url::fromRoute('system.db_update')->toString()]); } @@ -959,7 +960,7 @@ function system_requirements($phase): array { } $entity_update_issues = \Drupal::service('renderer')->renderInIsolation($build); - $requirements['entity_update']['severity'] = REQUIREMENT_ERROR; + $requirements['entity_update']['severity'] = RequirementSeverity::Error; $requirements['entity_update']['value'] = t('Mismatched entity and/or field definitions'); $requirements['entity_update']['description'] = t('The following changes were detected in the entity type and field definitions. @updates', ['@updates' => $entity_update_issues]); } @@ -971,7 +972,7 @@ function system_requirements($phase): array { $requirements['deployment identifier'] = [ 'title' => t('Deployment identifier'), 'value' => $deployment_identifier, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; } } @@ -981,7 +982,7 @@ function system_requirements($phase): array { if (Settings::get('update_free_access')) { $requirements['update access'] = [ 'value' => t('Not protected'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The update.php script is accessible to everyone without authentication check, which is a security risk. You must change the @settings_name value in your settings.php back to FALSE.', ['@settings_name' => '$settings[\'update_free_access\']']), ]; } @@ -1023,7 +1024,7 @@ function system_requirements($phase): array { '#markup' => $message, ], ], - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; }; $profile = \Drupal::installProfile(); @@ -1058,7 +1059,7 @@ function system_requirements($phase): array { 'title' => t('Unresolved dependency'), 'description' => t('@name requires this module.', ['@name' => $name]), 'value' => t('@required_name (Missing)', ['@required_name' => $required_module]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -1072,7 +1073,7 @@ function system_requirements($phase): array { 'title' => t('Unresolved dependency'), 'description' => t('@name requires this module and version. Currently using @required_name version @version', ['@name' => $name, '@required_name' => $required_name, '@version' => $version]), 'value' => t('@required_name (Version @compatibility required)', ['@required_name' => $required_name, '@compatibility' => $requirement->getConstraintString()]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -1272,9 +1273,9 @@ function system_requirements($phase): array { Unicode::STATUS_ERROR => t('Error'), ]; $severities = [ - Unicode::STATUS_SINGLEBYTE => REQUIREMENT_WARNING, + Unicode::STATUS_SINGLEBYTE => RequirementSeverity::Warning, Unicode::STATUS_MULTIBYTE => NULL, - Unicode::STATUS_ERROR => REQUIREMENT_ERROR, + Unicode::STATUS_ERROR => RequirementSeverity::Error, ]; $failed_check = Unicode::check(); $library = Unicode::getStatus(); @@ -1299,7 +1300,7 @@ function system_requirements($phase): array { if (!\Drupal::moduleHandler()->moduleExists('update')) { $requirements['update status'] = [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Update notifications are not enabled. It is <strong>highly recommended</strong> that you install the Update Status module from the <a href=":module">module administration page</a> in order to stay up-to-date on new releases. For more information, <a href=":update">Update status handbook page</a>.', [ ':update' => 'https://www.drupal.org/documentation/modules/update', ':module' => Url::fromRoute('system.modules_list')->toString(), @@ -1317,7 +1318,7 @@ function system_requirements($phase): array { $requirements['rebuild access'] = [ 'title' => t('Rebuild access'), 'value' => t('Enabled'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The rebuild_access setting is enabled in settings.php. It is recommended to have this setting disabled unless you are performing a rebuild.'), ]; } @@ -1338,7 +1339,7 @@ function system_requirements($phase): array { $requirements['php_session_samesite'] = [ 'title' => t('SameSite cookie attribute'), 'value' => $samesite, - 'severity' => $valid ? REQUIREMENT_OK : REQUIREMENT_WARNING, + 'severity' => $valid ? RequirementSeverity::OK : RequirementSeverity::Warning, 'description' => t('This attribute should be explicitly set to Lax, Strict or None. If set to None then the request must be made via HTTPS. See <a href=":url" target="_blank">PHP documentation</a>', [ ':url' => 'https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-samesite', ]), @@ -1354,7 +1355,7 @@ function system_requirements($phase): array { 'title' => t('Trusted Host Settings'), 'value' => t('Not enabled'), 'description' => t('The trusted_host_patterns setting is not configured in settings.php. This can lead to security vulnerabilities. It is <strong>highly recommended</strong> that you configure this. See <a href=":url">Protecting against HTTP HOST Header attacks</a> for more information.', [':url' => 'https://www.drupal.org/docs/installing-drupal/trusted-host-settings']), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } else { @@ -1383,7 +1384,7 @@ function system_requirements($phase): array { 'title' => t('Database driver provided by module'), 'value' => t('Not installed'), 'description' => t('The current database driver is provided by the module: %module. The module is currently not installed. You should immediately <a href=":install">install</a> the module.', ['%module' => $provider, ':install' => Url::fromRoute('system.modules_list')->toString()]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1403,7 +1404,7 @@ function system_requirements($phase): array { 'title' => t('Xdebug settings'), 'value' => t('xdebug.max_nesting_level is set to %value.', ['%value' => $current_nesting_level]), 'description' => t('Set <code>xdebug.max_nesting_level=@level</code> in your PHP configuration as some pages in your Drupal site will not work when this setting is too low.', ['@level' => $minimum_nesting_level]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1420,7 +1421,7 @@ function system_requirements($phase): array { $requirements['max_path_on_windows'] = [ 'title' => t('Windows installation depth'), 'description' => t('The public files directory path is %depth characters. Paths longer than 120 characters will cause problems on Windows.', ['%depth' => $depth]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1430,7 +1431,7 @@ function system_requirements($phase): array { 'title' => t('Limited date range'), 'value' => t('Your PHP installation has a limited date range.'), 'description' => t('You are running on a system where PHP is compiled or limited to using 32-bit integers. This will limit the range of dates and timestamps to the years 1901-2038. Read about the <a href=":url">limitations of 32-bit PHP</a>.', [':url' => 'https://www.drupal.org/docs/system-requirements/limitations-of-32-bit-php']), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -1443,7 +1444,7 @@ function system_requirements($phase): array { 'title' => t('Configuration install'), 'value' => $install_state['parameters']['profile'], 'description' => t('The selected profile has a hook_install() implementation and therefore can not be installed from configuration.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1457,7 +1458,7 @@ function system_requirements($phase): array { $requirements['install_profile_in_settings'] = [ 'title' => t('Install profile in settings'), 'value' => t("Drupal 9 no longer uses the \$settings['install_profile'] value in settings.php and it should be removed."), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } @@ -1501,7 +1502,7 @@ function system_requirements($phase): array { '@previous_major' => 9, ':url' => 'https://www.drupal.org/docs/upgrading-drupal/drupal-8-and-higher', ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } else { @@ -1513,7 +1514,7 @@ function system_requirements($phase): array { '@last_removed_version' => $data['last_removed'], '@installed_version' => $data['installed_version'], ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1541,7 +1542,7 @@ function system_requirements($phase): array { $requirements[$module . '_post_update_removed'] = [ 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), 'description' => $description, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1575,7 +1576,7 @@ function system_requirements($phase): array { '@future_update' => $future_update, '@future_version_string' => $future_version_string, ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; break; } @@ -1598,7 +1599,7 @@ function system_requirements($phase): array { 'system.development_settings', )->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } $render_cache_disabled = $development_settings->get('disable_rendered_output_cache_bins', FALSE); @@ -1611,7 +1612,7 @@ function system_requirements($phase): array { 'system.development_settings', )->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } @@ -1702,7 +1703,7 @@ function _system_advisories_requirements(array &$requirements): void { } catch (ClientExceptionInterface $exception) { $requirements['system_advisories']['title'] = t('Critical security announcements'); - $requirements['system_advisories']['severity'] = REQUIREMENT_WARNING; + $requirements['system_advisories']['severity'] = RequirementSeverity::Warning; $requirements['system_advisories']['description'] = ['#theme' => 'system_security_advisories_fetch_error_message']; Error::logException(\Drupal::logger('system'), $exception, 'Failed to retrieve security advisory data.'); return; @@ -1710,10 +1711,10 @@ function _system_advisories_requirements(array &$requirements): void { if (!empty($advisories)) { $advisory_links = []; - $severity = REQUIREMENT_WARNING; + $severity = RequirementSeverity::Warning; foreach ($advisories as $advisory) { if (!$advisory->isPsa()) { - $severity = REQUIREMENT_ERROR; + $severity = RequirementSeverity::Error; } $advisory_links[] = new Link($advisory->getTitle(), Url::fromUri($advisory->getUrl())); } diff --git a/core/modules/system/system.libraries.yml b/core/modules/system/system.libraries.yml index 03baf83d3bb..af0eeea05d2 100644 --- a/core/modules/system/system.libraries.yml +++ b/core/modules/system/system.libraries.yml @@ -9,7 +9,6 @@ base: css/components/hidden.module.css: { weight: -10 } css/components/item-list.module.css: { weight: -10 } css/components/js.module.css: { weight: -10 } - css/components/position-container.module.css: { weight: -10 } css/components/reset-appearance.module.css: { weight: -10 } admin: diff --git a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz Binary files differindex 5d8c9974469..077d0645ddc 100644 --- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz +++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz diff --git a/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz b/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz Binary files differindex 423f49a1d40..5db0b3a5aae 100644 --- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz +++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz diff --git a/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install b/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install index beaa3cd15b7..483a1d01717 100644 --- a/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install +++ b/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install @@ -7,6 +7,8 @@ declare(strict_types=1); +use Drupal\Core\Extension\Requirement\RequirementSeverity; + /** * Implements hook_requirements(). */ @@ -14,7 +16,7 @@ function experimental_module_requirements_test_requirements(): array { $requirements = []; if (\Drupal::state()->get('experimental_module_requirements_test_requirements', FALSE)) { $requirements['experimental_module_requirements_test_requirements'] = [ - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The Experimental Test Requirements module can not be installed.'), ]; } diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php index 542c4e162e2..78328f9f8e4 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php @@ -35,32 +35,28 @@ class FormTestClickedButtonForm extends FormBase { '#type' => 'textfield', ]; + // Get button configurations, filter out NULL values. + $args = array_filter([$first, $second, $third]); + + // Define button types for each argument. + $button_types = [ + 's' => 'submit', + 'i' => 'image_button', + 'b' => 'button', + ]; + // Loop through each path argument, adding buttons based on the information // in the argument. For example, if the path is // form-test/clicked-button/s/i/rb, then 3 buttons are added: a 'submit', an // 'image_button', and a 'button' with #access=FALSE. This enables form.test // to test a variety of combinations. - $i = 0; - $args = [$first, $second, $third]; - foreach ($args as $arg) { - $name = 'button' . ++$i; - // 's', 'b', or 'i' in the argument define the button type wanted. - if (!is_string($arg)) { - $type = NULL; - } - elseif (str_contains($arg, 's')) { - $type = 'submit'; - } - elseif (str_contains($arg, 'b')) { - $type = 'button'; - } - elseif (str_contains($arg, 'i')) { - $type = 'image_button'; - } - else { - $type = NULL; - } - if (isset($type)) { + foreach ($args as $index => $arg) { + // Get the button type based on the index of the argument. + $type = $button_types[$arg] ?? NULL; + $name = 'button' . ($index + 1); + + if ($type) { + // Define the button. $form[$name] = [ '#type' => $type, '#name' => $name, diff --git a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php index 908d0d8d454..09dbf982cf7 100644 --- a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php +++ b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php @@ -253,7 +253,11 @@ class TestToolkit extends ImageToolkitBase { * IMAGETYPE_* constant (e.g. IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.). */ protected static function supportedTypes() { - return [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF]; + $types = [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF]; + if (\Drupal::keyValue('image_test')->get('avif_enabled', FALSE)) { + $types[] = IMAGETYPE_AVIF; + } + return $types; } /** diff --git a/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php b/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php index 4d7367ff414..b6a532a1802 100644 --- a/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php +++ b/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\module_install_unmet_requirements\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Provides method for checking requirements during install time. @@ -17,7 +18,7 @@ class ModuleInstallUnmetRequirementsRequirements implements InstallRequirementsI public static function getRequirements(): array { $requirements['testing_requirements'] = [ 'title' => t('Testing requirements'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Testing requirements failed requirements.'), ]; diff --git a/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php b/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php index 0fa4b2f6f80..31358a595d7 100644 --- a/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php +++ b/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\module_runtime_requirements\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -24,13 +25,13 @@ class ModuleRuntimeRequirementsHooks { 'title' => $this->t('RuntimeError'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], 'test.runtime.error.alter' => [ 'title' => $this->t('RuntimeError'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } @@ -44,7 +45,7 @@ class ModuleRuntimeRequirementsHooks { 'title' => $this->t('RuntimeWarning'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Warning.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php index 1cbb9e6b422..db923382a21 100644 --- a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php +++ b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php @@ -4,19 +4,19 @@ declare(strict_types=1); namespace Drupal\module_test_oop_preprocess\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for module_test_oop_preprocess. */ class ModuleTestOopPreprocessThemeHooks { - #[Preprocess] + #[Hook('preprocess')] public function rootPreprocess($arg): mixed { return $arg; } - #[Preprocess('test')] + #[Hook('preprocess_test')] public function preprocessTest($arg): mixed { return $arg; } diff --git a/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php b/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php index f0666222f14..073baba95c9 100644 --- a/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php +++ b/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\module_update_requirements\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -24,13 +25,13 @@ class ModuleUpdateRequirementsHooks { 'title' => $this->t('UpdateError'), 'value' => $this->t('None'), 'description' => $this->t('Update Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], 'test.update.error.alter' => [ 'title' => $this->t('UpdateError'), 'value' => $this->t('None'), 'description' => $this->t('Update Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } @@ -44,7 +45,7 @@ class ModuleUpdateRequirementsHooks { 'title' => $this->t('UpdateWarning'), 'value' => $this->t('None'), 'description' => $this->t('Update Warning.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/system/tests/modules/requirements1_test/requirements1_test.install b/core/modules/system/tests/modules/requirements1_test/requirements1_test.install index fb84be133cd..a93f726fafd 100644 --- a/core/modules/system/tests/modules/requirements1_test/requirements1_test.install +++ b/core/modules/system/tests/modules/requirements1_test/requirements1_test.install @@ -7,6 +7,8 @@ declare(strict_types=1); +use Drupal\Core\Extension\Requirement\RequirementSeverity; + /** * Implements hook_requirements(). * @@ -19,20 +21,20 @@ function requirements1_test_requirements($phase): array { if ('install' == $phase) { $requirements['requirements1_test'] = [ 'title' => t('Requirements 1 Test'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Requirements 1 Test failed requirements.'), ]; } $requirements['requirements1_test_alterable'] = [ 'title' => t('Requirements 1 Test Alterable'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('A requirement that will be altered.'), ]; $requirements['requirements1_test_deletable'] = [ 'title' => t('Requirements 1 Test Deletable'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => t('A requirement that will be deleted.'), ]; diff --git a/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php b/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php index c766f6f423a..ce3eebfb35b 100644 --- a/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php +++ b/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\requirements1_test\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -22,7 +23,7 @@ class Requirements1TestHooks { // Change the title. $requirements['requirements1_test_alterable']['title'] = $this->t('Requirements 1 Test - Changed'); // Decrease the severity. - $requirements['requirements1_test_alterable']['severity'] = REQUIREMENT_WARNING; + $requirements['requirements1_test_alterable']['severity'] = RequirementSeverity::Warning; // Delete 'requirements1_test_deletable', unset($requirements['requirements1_test_deletable']); } diff --git a/core/modules/system/tests/modules/session_test/session_test.routing.yml b/core/modules/system/tests/modules/session_test/session_test.routing.yml index fe85de11032..f11bd86b4d7 100644 --- a/core/modules/system/tests/modules/session_test/session_test.routing.yml +++ b/core/modules/system/tests/modules/session_test/session_test.routing.yml @@ -179,3 +179,25 @@ session_test.trigger_write_exception: no_cache: TRUE requirements: _access: 'TRUE' + +session_test.legacy_get: + path: '/session-test/legacy-get' + defaults: + _title: 'Legacy session value' + _controller: '\Drupal\session_test\Controller\LegacySessionTestController::get' + options: + no_cache: TRUE + requirements: + _access: 'TRUE' + +session_test.legacy_set: + path: '/session-test/legacy-set/{test_value}' + defaults: + _title: 'Set legacy session value' + _controller: '\Drupal\session_test\Controller\LegacySessionTestController::set' + options: + no_cache: TRUE + converters: + test_value: '\s+' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php new file mode 100644 index 00000000000..a1438a0108e --- /dev/null +++ b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\session_test\Controller; + +use Drupal\Core\Controller\ControllerBase; + +/** + * Controller providing page callbacks for legacy session tests. + */ +class LegacySessionTestController extends ControllerBase { + + /** + * Prints the stored session value to the screen. + */ + public function get(): array { + return empty($_SESSION['legacy_test_value']) + ? [] + : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['legacy_test_value']])]; + } + + /** + * Stores a value in $_SESSION['legacy_test_value']. + * + * @param string $test_value + * A session value. + */ + public function set(string $test_value): array { + $_SESSION['legacy_test_value'] = $test_value; + + return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])]; + } + +} diff --git a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php index 9c7bb97e24b..461581abaa7 100644 --- a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php +++ b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php @@ -11,20 +11,21 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; /** - * Controller providing page callbacks for the action admin interface. + * Controller providing page callbacks for session tests. */ class SessionTestController extends ControllerBase { /** * Prints the stored session value to the screen. * - * @return string - * A notification message. + * @param \Symfony\Component\HttpFoundation\Request $request + * The incoming request. */ - public function get() { - return empty($_SESSION['session_test_value']) + public function get(Request $request): array { + $value = $request->getSession()->get('session_test_value'); + return empty($value) ? [] - : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['session_test_value']])]; + : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $value])]; } /** @@ -32,11 +33,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return string - * A notification message. */ - public function getFromSessionObject(Request $request) { + public function getFromSessionObject(Request $request): array { $value = $request->getSession()->get("session_test_key"); return empty($value) ? [] @@ -48,16 +46,13 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return string - * A notification message with session ID. */ - public function getId(Request $request) { - // Set a value in $_SESSION, so that SessionManager::save() will start + public function getId(Request $request): array { + // Set a value in session, so that SessionManager::save() will start // a session. - $_SESSION['test'] = 'test'; - - $request->getSession()->save(); + $session = $request->getSession(); + $session->set('test', 'test'); + $session->save(); return ['#markup' => 'session_id:' . session_id() . "\n"]; } @@ -67,11 +62,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return string - * A notification message with session ID. */ - public function getIdFromCookie(Request $request) { + public function getIdFromCookie(Request $request): array { return [ '#markup' => 'session_id:' . $request->cookies->get(session_name()) . "\n", '#cache' => ['contexts' => ['cookies:' . session_name()]], @@ -79,16 +71,15 @@ class SessionTestController extends ControllerBase { } /** - * Stores a value in $_SESSION['session_test_value']. + * Stores a value in 'session_test_value' session attribute. * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. * @param string $test_value * A session value. - * - * @return string - * A notification message. */ - public function set($test_value) { - $_SESSION['session_test_value'] = $test_value; + public function set(Request $request, $test_value): array { + $request->getSession()->set('session_test_value', $test_value); return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])]; } @@ -96,25 +87,21 @@ class SessionTestController extends ControllerBase { /** * Turns off session saving and then tries to save a value anyway. * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. * @param string $test_value * A session value. - * - * @return string - * A notification message. */ - public function noSet($test_value) { + public function noSet(Request $request, $test_value): array { \Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE); - $this->set($test_value); + $this->set($request, $test_value); return ['#markup' => $this->t('session saving was disabled, and then %val was set', ['%val' => $test_value])]; } /** * Sets a message to me displayed on the following page. - * - * @return string - * A notification message. */ - public function setMessage() { + public function setMessage(): Response { $this->messenger()->addStatus($this->t('This is a dummy message.')); return new Response((string) $this->t('A message was set.')); // Do not return anything, so the current request does not result in a @@ -124,11 +111,8 @@ class SessionTestController extends ControllerBase { /** * Sets a message but call drupal_save_session(FALSE). - * - * @return string - * A notification message. */ - public function setMessageButDoNotSave() { + public function setMessageButDoNotSave(): array { \Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE); $this->setMessage(); return ['#markup' => '']; @@ -136,11 +120,8 @@ class SessionTestController extends ControllerBase { /** * Only available if current user is logged in. - * - * @return string - * A notification message. */ - public function isLoggedIn() { + public function isLoggedIn(): array { return ['#markup' => $this->t('User is logged in.')]; } @@ -149,20 +130,13 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * The response. */ - public function traceHandler(Request $request) { - // Start a session if necessary, set a value and then save and close it. - $request->getSession()->start(); - if (empty($_SESSION['trace-handler'])) { - $_SESSION['trace-handler'] = 1; - } - else { - $_SESSION['trace-handler']++; - } - $request->getSession()->save(); + public function traceHandler(Request $request): Response { + // Increment trace-handler counter and save the session. + $session = $request->getSession(); + $counter = $session->get('trace-handler', 0); + $session->set('trace-handler', $counter + 1); + $session->save(); // Collect traces and return them in JSON format. $trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy(); @@ -182,15 +156,13 @@ class SessionTestController extends ControllerBase { * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * The response. - * * @throws \AssertionError */ - public function traceHandlerRewriteUnmodified(Request $request) { + public function traceHandlerRewriteUnmodified(Request $request): Response { // Assert that there is an existing session with stacked handler trace data. + $session = $request->getSession(); assert( - is_int($_SESSION['trace-handler']) && $_SESSION['trace-handler'] > 0, + is_int($session->get('trace-handler')) && $session->get('trace-handler') > 0, 'Existing stacked session handler trace not found' ); @@ -199,7 +171,7 @@ class SessionTestController extends ControllerBase { ini_get('session.lazy_write'), 'session.lazy_write must be enabled to invoke updateTimestamp()' ); - $request->getSession()->save(); + $session->save(); // Collect traces and return them in JSON format. $trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy(); @@ -212,11 +184,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * A response object containing the session values and the user ID. */ - public function getSession(Request $request) { + public function getSession(Request $request): Response { return new JsonResponse(['session' => $request->getSession()->all(), 'user' => $this->currentUser()->id()]); } @@ -227,11 +196,8 @@ class SessionTestController extends ControllerBase { * The request object. * @param string $test_value * A value to set on the session. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * A response object containing the session values and the user ID. */ - public function setSession(Request $request, $test_value) { + public function setSession(Request $request, $test_value): Response { $session = $request->getSession(); $session->set('test_value', $test_value); return new JsonResponse(['session' => $session->all(), 'user' => $this->currentUser()->id()]); @@ -242,11 +208,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function setSessionBagFlag(Request $request) { + public function setSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); $bag->setFlag(); @@ -258,11 +221,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function clearSessionBagFlag(Request $request) { + public function clearSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); $bag->clearFlag(); @@ -274,11 +234,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function hasSessionBagFlag(Request $request) { + public function hasSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); return new Response(empty($bag->hasFlag()) @@ -293,7 +250,7 @@ class SessionTestController extends ControllerBase { * @param \Symfony\Component\HttpFoundation\Request $request * The request object. */ - public function triggerWriteException(Request $request) { + public function triggerWriteException(Request $request): Response { $session = $request->getSession(); $session->set('test_value', 'Ensure session contains some data'); diff --git a/core/modules/system/tests/modules/test_htmx/css/style.css b/core/modules/system/tests/modules/test_htmx/css/style.css new file mode 100644 index 00000000000..75b757dbe3c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/css/style.css @@ -0,0 +1,3 @@ +.ajax-content { + background-color: red; +} diff --git a/core/modules/system/tests/modules/test_htmx/js/behavior.js b/core/modules/system/tests/modules/test_htmx/js/behavior.js new file mode 100644 index 00000000000..5ca13501cee --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/js/behavior.js @@ -0,0 +1,14 @@ +((Drupal, once) => { + Drupal.behaviors.htmx_test = { + attach(context, settings) { + once('htmx-init', '.ajax-content', context).forEach((el) => { + el.innerText = 'initialized'; + }); + }, + detach(context, settings, trigger) { + once.remove('htmx-init', '.ajax-content', context).forEach((el) => { + el.remove(); + }); + }, + }; +})(Drupal, once); diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php new file mode 100644 index 00000000000..9045a4c8b9c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; + +/** + * Returns responses for HTMX Test Attachments routes. + */ +final class HtmxTestAttachmentsController extends ControllerBase { + + /** + * Builds the response. + * + * @return mixed[] + * A render array. + */ + public function page(): array { + return self::generateHtmxButton(); + } + + /** + * Builds the HTMX response. + * + * @return mixed[] + * A render array. + */ + public function replace(): array { + $build['content'] = [ + '#type' => 'container', + '#attached' => [ + 'library' => ['test_htmx/assets'], + ], + '#attributes' => [ + 'class' => ['ajax-content'], + ], + 'example' => ['#markup' => 'Initial Content'], + ]; + + return $build; + } + + /** + * Static helper to for reusable render array. + * + * @return array + * The render array. + */ + public static function generateHtmxButton(): array { + $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['replace'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#attributes' => [ + 'type' => 'button', + 'name' => 'replace', + 'data-hx-get' => $url->toString(), + 'data-hx-select' => 'div.ajax-content', + 'data-hx-target' => '[data-drupal-htmx-target]', + ], + '#value' => 'Click this', + '#attached' => [ + 'library' => [ + 'core/drupal.htmx', + ], + ], + ]; + + $build['content'] = [ + '#type' => 'container', + '#attributes' => [ + 'data-drupal-htmx-target' => TRUE, + 'class' => ['htmx-test-container'], + ], + ]; + + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php new file mode 100644 index 00000000000..8fffbbc5f40 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\test_htmx\Controller\HtmxTestAttachmentsController; + +/** + * A small form used to insert an HTMX powered element using ajax API. + */ +class HtmxTestAjaxForm extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'htmx_test_ajax_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $build = [ + 'ajax-button' => [ + '#type' => 'button', + '#value' => 'Trigger Ajax', + '#submit_button' => FALSE, + '#ajax' => [ + 'callback' => [ + HtmxTestAttachmentsController::class, + 'generateHtmxButton', + ], + 'wrapper' => 'ajax-test-container', + ], + ], + '#suffix' => '<div id="ajax-test-container"></div>', + ]; + + return $build; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void {} + +} diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml new file mode 100644 index 00000000000..c713e0624d9 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml @@ -0,0 +1,4 @@ +name: 'HTMX Test Fixtures' +type: module +description: 'Test fixtures for HTMX integration' +package: Testing diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml new file mode 100644 index 00000000000..31ac1d2b8ab --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml @@ -0,0 +1,10 @@ +assets: + version: VERSION + js: + js/behavior.js: {} + css: + theme: + css/style.css: {} + dependencies: + - core/drupal + - core/once diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml new file mode 100644 index 00000000000..406c3027f3b --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,23 @@ +test_htmx.attachments.page: + path: '/htmx-test-attachments/page' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page' + requirements: + _permission: 'access content' + +test_htmx.attachments.replace: + path: '/htmx-test-attachments/replace' + defaults: + _title: 'Ajax Content' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' + requirements: + _permission: 'access content' + +test_htmx.attachments.ajax: + path: '/htmx-test-attachments/ajax' + defaults: + _title: 'Ajax' + _form: '\Drupal\test_htmx\Form\HtmxTestAjaxForm' + requirements: + _permission: 'access content' diff --git a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php index fc48756de51..7bfc10ef0ef 100644 --- a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php +++ b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\theme_test\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for theme_test. @@ -14,7 +14,7 @@ class ThemeTestThemeHooks { /** * Implements hook_preprocess_HOOK(). */ - #[Preprocess('theme_test_preprocess_suggestions__monkey')] + #[Hook('preprocess_theme_test_preprocess_suggestions__monkey')] public function preprocessTestSuggestions(&$variables): void { $variables['foo'] = 'Monkey'; } diff --git a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php index 272ad65eff3..f5d0c150118 100644 --- a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php +++ b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php @@ -24,7 +24,7 @@ class TestLoader implements LoaderInterface { /** * {@inheritdoc} */ - public function exists(string $name) { + public function exists(string $name): bool { return TRUE; } diff --git a/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php b/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php index 5927e31e460..e93fe8bb80f 100644 --- a/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php +++ b/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update_script_test\Hook; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; /** @@ -25,21 +26,21 @@ class UpdateScriptTestRequirements { // Set a requirements warning or error when the test requests it. $requirement_type = $this->configFactory->get('update_script_test.settings')->get('requirement_type'); switch ($requirement_type) { - case REQUIREMENT_WARNING: + case RequirementSeverity::Warning->value: $requirements['update_script_test'] = [ 'title' => 'Update script test', 'value' => 'Warning', 'description' => 'This is a requirements warning provided by the update_script_test module.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; break; - case REQUIREMENT_ERROR: + case RequirementSeverity::Error->value: $requirements['update_script_test'] = [ 'title' => 'Update script test', 'value' => 'Error', 'description' => 'This is a (buggy description fixed in update_script_test_requirements_alter()) requirements error provided by the update_script_test module.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; break; } @@ -51,7 +52,7 @@ class UpdateScriptTestRequirements { */ #[Hook('update_requirements_alter')] public function updateAlter(array &$requirements): void { - if (isset($requirements['update_script_test']) && $requirements['update_script_test']['severity'] === REQUIREMENT_ERROR) { + if (isset($requirements['update_script_test']) && $requirements['update_script_test']['severity'] === RequirementSeverity::Error) { $requirements['update_script_test']['description'] = 'This is a requirements error provided by the update_script_test module.'; } } diff --git a/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php b/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php index de96ce3e36a..3199527bd05 100644 --- a/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php +++ b/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update_test_schema\Hook; use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Url; @@ -22,7 +23,7 @@ class UpdateTestSchemaRequirements { $requirements['path_alias_test'] = [ 'title' => 'Path alias test', 'value' => 'Check a path alias for the admin page', - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => new FormattableMarkup('Visit <a href=":link">the structure page</a> to do many useful things.', [ ':link' => Url::fromRoute('system.admin_structure')->toString(), ]), diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php index 6812903dccc..f45e45e6159 100644 --- a/core/modules/system/tests/src/Functional/Form/FormTest.php +++ b/core/modules/system/tests/src/Functional/Form/FormTest.php @@ -6,7 +6,6 @@ namespace Drupal\Tests\system\Functional\Form; use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\Html; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Form\FormState; use Drupal\Core\Render\Element; use Drupal\Core\Url; @@ -199,7 +198,7 @@ class FormTest extends BrowserTestBase { $expected_key = array_search($error->getText(), $expected); // If the error message is not one of the expected messages, fail. if ($expected_key === FALSE) { - $this->fail(new FormattableMarkup("Unexpected error message: @error", ['@error' => $error[0]])); + $this->fail("Unexpected error message: " . $error[0]); } // Remove the expected message from the list once it is found. else { @@ -209,7 +208,7 @@ class FormTest extends BrowserTestBase { // Fail if any expected messages were not found. foreach ($expected as $not_found) { - $this->fail(new FormattableMarkup("Found error message: @error", ['@error' => $not_found])); + $this->fail("Found error message: " . $not_found); } // Verify that input elements are still empty. @@ -610,14 +609,6 @@ class FormTest extends BrowserTestBase { public function testNumber(): void { $form = \Drupal::formBuilder()->getForm('\Drupal\form_test\Form\FormTestNumberForm'); - // Array with all the error messages to be checked. - $error_messages = [ - 'no_number' => '%name must be a number.', - 'too_low' => '%name must be higher than or equal to %min.', - 'too_high' => '%name must be lower than or equal to %max.', - 'step_mismatch' => '%name is not a valid number.', - ]; - // The expected errors. $expected = [ 'integer_no_number' => 'no_number', @@ -648,21 +639,26 @@ class FormTest extends BrowserTestBase { $this->submitForm([], 'Submit'); foreach ($expected as $element => $error) { - // Create placeholder array. - $placeholders = [ - '%name' => $form[$element]['#title'], - '%min' => $form[$element]['#min'] ?? '0', - '%max' => $form[$element]['#max'] ?? '0', + // Array with all the error messages to be checked. + $name = $form[$element]['#title']; + $min = $form[$element]['#min'] ?? '0'; + $max = $form[$element]['#max'] ?? '0'; + + $error_messages = [ + 'no_number' => "<em class=\"placeholder\">$name</em> must be a number.", + 'too_low' => "<em class=\"placeholder\">$name</em> must be higher than or equal to <em class=\"placeholder\">$min</em>.", + 'too_high' => "<em class=\"placeholder\">$name</em> must be lower than or equal to <em class=\"placeholder\">$max</em>.", + 'step_mismatch' => "<em class=\"placeholder\">$name</em> is not a valid number.", ]; foreach ($error_messages as $id => $message) { // Check if the error exists on the page, if the current message ID is // expected. Otherwise ensure that the error message is not present. if ($id === $error) { - $this->assertSession()->responseContains(new FormattableMarkup($message, $placeholders)); + $this->assertSession()->responseContains($message); } else { - $this->assertSession()->responseNotContains(new FormattableMarkup($message, $placeholders)); + $this->assertSession()->responseNotContains($message); } } } diff --git a/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php b/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php index 388e83f6fcc..b297647194a 100644 --- a/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php +++ b/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\SecurityAdvisories; use Drupal\advisory_feed_test\AdvisoryTestClientMiddleware; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\Traits\Core\CronRunTrait; @@ -140,10 +141,10 @@ class SecurityAdvisoryTest extends BrowserTestBase { // If both PSA and non-PSA advisories are displayed they should be displayed // as errors. - $this->assertStatusReportLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertStatusReportLinks($mixed_advisory_links, RequirementSeverity::Error); // The advisories will be displayed on admin pages if the response was // stored from the status report request. - $this->assertAdminPageLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertAdminPageLinks($mixed_advisory_links, RequirementSeverity::Error); // Confirm that a user without the correct permission will not see the // advisories on admin pages. @@ -159,8 +160,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { $this->drupalLogin($this->user); // Test cache. AdvisoryTestClientMiddleware::setTestEndpoint($this->nonWorkingEndpoint); - $this->assertAdminPageLinks($mixed_advisory_links, REQUIREMENT_ERROR); - $this->assertStatusReportLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertAdminPageLinks($mixed_advisory_links, RequirementSeverity::Error); + $this->assertStatusReportLinks($mixed_advisory_links, RequirementSeverity::Error); // Tests transmit errors with a JSON endpoint. $this->tempStore->delete('advisories_response'); @@ -195,8 +196,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { $this->assertAdvisoriesNotDisplayed($psa_advisory_links, ['system.admin']); // If only PSA advisories are displayed they should be displayed as // warnings. - $this->assertStatusReportLinks($psa_advisory_links, REQUIREMENT_WARNING); - $this->assertAdminPageLinks($psa_advisory_links, REQUIREMENT_WARNING); + $this->assertStatusReportLinks($psa_advisory_links, RequirementSeverity::Warning); + $this->assertAdminPageLinks($psa_advisory_links, RequirementSeverity::Warning); AdvisoryTestClientMiddleware::setTestEndpoint($this->workingEndpointNonPsaOnly, TRUE); $non_psa_advisory_links = [ @@ -205,8 +206,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { ]; // If only non-PSA advisories are displayed they should be displayed as // errors. - $this->assertStatusReportLinks($non_psa_advisory_links, REQUIREMENT_ERROR); - $this->assertAdminPageLinks($non_psa_advisory_links, REQUIREMENT_ERROR); + $this->assertStatusReportLinks($non_psa_advisory_links, RequirementSeverity::Error); + $this->assertAdminPageLinks($non_psa_advisory_links, RequirementSeverity::Error); // Confirm that advisory fetching can be disabled after enabled. $this->config('system.advisories')->set('enabled', FALSE)->save(); @@ -220,16 +221,15 @@ class SecurityAdvisoryTest extends BrowserTestBase { * * @param string[] $expected_link_texts * The expected links' text. - * @param int $error_or_warning - * Whether the links are a warning or an error. Should be one of the - * REQUIREMENT_* constants. + * @param \Drupal\Core\Extension\Requirement\RequirementSeverity $error_or_warning + * Whether the links are a warning or an error. * * @internal */ - private function assertAdminPageLinks(array $expected_link_texts, int $error_or_warning): void { + private function assertAdminPageLinks(array $expected_link_texts, RequirementSeverity $error_or_warning): void { $assert = $this->assertSession(); $this->drupalGet(Url::fromRoute('system.admin')); - if ($error_or_warning === REQUIREMENT_ERROR) { + if ($error_or_warning === RequirementSeverity::Error) { $assert->pageTextContainsOnce('Error message'); $assert->pageTextNotContains('Warning message'); } @@ -247,16 +247,15 @@ class SecurityAdvisoryTest extends BrowserTestBase { * * @param string[] $expected_link_texts * The expected links' text. - * @param int $error_or_warning - * Whether the links are a warning or an error. Should be one of the - * REQUIREMENT_* constants. + * @param \Drupal\Core\Extension\Requirement\RequirementSeverity::Error|\Drupal\Core\Extension\Requirement\RequirementSeverity::Warning $error_or_warning + * Whether the links are a warning or an error. * * @internal */ - private function assertStatusReportLinks(array $expected_link_texts, int $error_or_warning): void { + private function assertStatusReportLinks(array $expected_link_texts, RequirementSeverity $error_or_warning): void { $this->drupalGet(Url::fromRoute('system.status')); $assert = $this->assertSession(); - $selector = 'h3#' . ($error_or_warning === REQUIREMENT_ERROR ? 'error' : 'warning') + $selector = 'h3#' . $error_or_warning->status() . ' ~ details.system-status-report__entry:contains("Critical security announcements")'; $assert->elementExists('css', $selector); foreach ($expected_link_texts as $expected_link_text) { diff --git a/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php new file mode 100644 index 00000000000..84ab1ed9d5b --- /dev/null +++ b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Session; + +use Drupal\Tests\BrowserTestBase; + +/** + * Drupal legacy session handling tests. + * + * @group legacy + * @group Session + */ +class LegacySessionTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['session_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests data persistence via the session_test module callbacks. + */ + public function testLegacyDataPersistence(): void { + $this->expectDeprecation('Storing values directly in $_SESSION is deprecated in drupal:11.2.0 and will become unsupported in drupal:12.0.0. Use $request->getSession()->set() instead. Affected keys: legacy_test_value. See https://www.drupal.org/node/3518527'); + $value = $this->randomMachineName(); + + // Verify that the session value is stored. + $this->drupalGet('session-test/legacy-set/' . $value); + $this->assertSession()->pageTextContains($value); + + // Verify that the session correctly returned the stored data for an + // authenticated user. + $this->drupalGet('session-test/legacy-get'); + $this->assertSession()->pageTextContains($value); + } + +} diff --git a/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php b/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php index 41d60b8a42a..6e47278edad 100644 --- a/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php +++ b/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\System; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Site\Settings; use Drupal\Tests\BrowserTestBase; @@ -58,7 +59,7 @@ class SitesDirectoryHardeningTest extends BrowserTestBase { // Manually trigger the requirements check. $requirements = $this->checkSystemRequirements(); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['configuration_files']['severity'], 'Warning severity is properly set.'); + $this->assertEquals(RequirementSeverity::Warning, $requirements['configuration_files']['severity'], 'Warning severity is properly set.'); $this->assertEquals('Protection disabled', (string) $requirements['configuration_files']['value']); $description = strip_tags((string) \Drupal::service('renderer')->renderInIsolation($requirements['configuration_files']['description'])); $this->assertStringContainsString('settings.php is not protected from modifications and poses a security risk.', $description); diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php index f0f78b23c99..5be7e48289f 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\UpdateSystem; use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Url; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\BrowserTestBase; @@ -149,7 +150,7 @@ class UpdateScriptTest extends BrowserTestBase { // First, run this test with pending updates to make sure they can be run // successfully. $this->drupalLogin($this->updateUser); - $update_script_test_config->set('requirement_type', REQUIREMENT_WARNING)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Warning->value)->save(); /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ $update_registry = \Drupal::service('update.update_hook_registry'); $update_registry->setInstalledVersion('update_script_test', $update_registry->getInstalledVersion('update_script_test') - 1); @@ -177,7 +178,7 @@ class UpdateScriptTest extends BrowserTestBase { // If there is a requirements error, it should be displayed even after // clicking the link to proceed (since the problem that triggered the error // has not been fixed). - $update_script_test_config->set('requirement_type', REQUIREMENT_ERROR)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Error->value)->save(); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->pageTextContains('This is a requirements error provided by the update_script_test module.'); $this->clickLink('try again'); @@ -185,7 +186,7 @@ class UpdateScriptTest extends BrowserTestBase { // Ensure that changes to a module's requirements that would cause errors // are displayed correctly. - $update_script_test_config->set('requirement_type', REQUIREMENT_OK)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::OK->value)->save(); \Drupal::state()->set('update_script_test.system_info_alter', ['dependencies' => ['a_module_that_does_not_exist']]); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->responseContains('a_module_that_does_not_exist (Missing)'); diff --git a/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php b/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php new file mode 100644 index 00000000000..c2bd82782b0 --- /dev/null +++ b/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Kernel\Block; + +use Drupal\Core\Path\PathMatcher; + +/** + * A class extending PatchMatcher for testing purposes. + */ +class StubPathMatcher extends PathMatcher { + + /** + * {@inheritdoc} + */ + public function isFrontPage(): bool { + return FALSE; + } + +} diff --git a/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php b/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php index e2ceead3475..6d4ff90d5f4 100644 --- a/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php +++ b/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php @@ -341,6 +341,13 @@ class SystemMenuBlockTest extends KernelTestBase { * @dataProvider configExpandedTestCases */ public function testConfigExpanded($active_route, $menu_block_level, $expected_items): void { + // Replace the path.matcher service so it always returns FALSE when + // checking whether a route is the front page. Otherwise, the default + // service throws an exception when checking routes because all of these + // are mocked. + $service_definition = $this->container->getDefinition('path.matcher'); + $service_definition->setClass(StubPathMatcher::class); + $block = $this->blockManager->createInstance('system_menu_block:' . $this->menu->id(), [ 'region' => 'footer', 'id' => 'machine_name', diff --git a/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php b/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php index 6c8c42da59e..82d866e985e 100644 --- a/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php +++ b/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php @@ -77,6 +77,8 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { * An array of test cases. */ public static function providerTestAccess(): array { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $c = new ContainerBuilder(); $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); @@ -84,7 +86,7 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { $c->set('cache_contexts_manager', $cache_contexts_manager); \Drupal::setContainer($c); - return [ + $data = [ 'No permission + unlocked' => [ [], 'unlocked', @@ -122,6 +124,13 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { AccessResult::allowed()->addCacheContexts(['user.permissions']), ], ]; + + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + + return $data; } } diff --git a/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php b/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php new file mode 100644 index 00000000000..630a3a997dd --- /dev/null +++ b/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Kernel\Element; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\KernelTests\KernelTestBase; +use Drupal\system\Element\StatusReportPage; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the status report page element. + * + * @group system + * @group legacy + */ +class StatusReportPageTest extends KernelTestBase { + + /** + * Tests the status report page element. + */ + public function testPeRenderCounters(): void { + $element = [ + '#requirements' => [ + 'foo' => [ + 'title' => 'Foo', + 'severity' => \REQUIREMENT_INFO, + ], + 'baz' => [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ], + 'wiz' => [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ], + ], + ]; + $this->expectDeprecation('Calling Drupal\system\Element\StatusReportPage::preRenderCounters() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939'); + $element = StatusReportPage::preRenderCounters($element); + + $error = $element['#counters']['error']; + $this->assertEquals(1, $error['#amount']); + $this->assertEquals('error', $error['#severity']); + + $warning = $element['#counters']['warning']; + $this->assertEquals(1, $warning['#amount']); + $this->assertEquals('warning', $warning['#severity']); + + $checked = $element['#counters']['checked']; + $this->assertEquals(1, $checked['#amount']); + $this->assertEquals('checked', $checked['#severity']); + + } + +} diff --git a/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php b/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php index 2258b08bc49..c22529a72db 100644 --- a/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php +++ b/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Kernel\Module; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\KernelTests\KernelTestBase; /** @@ -28,7 +29,7 @@ class RequirementsTest extends KernelTestBase { $requirements = $this->container->get('system.manager')->listRequirements(); // @see requirements1_test_requirements_alter() $this->assertEquals('Requirements 1 Test - Changed', $requirements['requirements1_test_alterable']['title']); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['requirements1_test_alterable']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['requirements1_test_alterable']['severity']); $this->assertArrayNotHasKey('requirements1_test_deletable', $requirements); } diff --git a/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php b/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php index af027b48051..e39e509cb14 100644 --- a/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php +++ b/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Kernel\System; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\KernelTests\KernelTestBase; @@ -31,7 +32,7 @@ class RunTimeRequirementsTest extends KernelTestBase { 'title' => 'RuntimeError', 'value' => 'None', 'description' => 'Runtime Error.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; $requirements = \Drupal::service('system.manager')->listRequirements()['test.runtime.error']; $this->assertEquals($testRequirements, $requirements); @@ -40,7 +41,7 @@ class RunTimeRequirementsTest extends KernelTestBase { 'title' => 'RuntimeWarning', 'value' => 'None', 'description' => 'Runtime Warning.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; $requirementsAlter = \Drupal::service('system.manager')->listRequirements()['test.runtime.error.alter']; $this->assertEquals($testRequirementsAlter, $requirementsAlter); diff --git a/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php index 7747682a42e..b751f6b52ba 100644 --- a/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php +++ b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\taxonomy\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\Core\Url; use Drupal\taxonomy\Entity\Vocabulary; @@ -101,9 +101,10 @@ class VocabularyUiTest extends TaxonomyTestBase { $link->click(); // Confirm deletion. - $this->assertSession()->responseContains(new FormattableMarkup('Are you sure you want to delete the vocabulary %name?', ['%name' => $edit['name']])); + $name = Html::escape($edit['name']); + $this->assertSession()->responseContains("Are you sure you want to delete the vocabulary <em class=\"placeholder\">$name</em>?"); $this->submitForm([], 'Delete'); - $this->assertSession()->responseContains(new FormattableMarkup('Deleted vocabulary %name.', ['%name' => $edit['name']])); + $this->assertSession()->responseContains("Deleted vocabulary <em class=\"placeholder\">$name</em>."); $this->container->get('entity_type.manager')->getStorage('taxonomy_vocabulary')->resetCache(); $this->assertNull(Vocabulary::load($edit['vid']), 'Vocabulary not found.'); } diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php index 1d9654dd505..511778daf20 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php @@ -10,6 +10,7 @@ use Drupal\node\Entity\Node; /** * Upgrade taxonomy term node associations. * + * @group #slow * @group migrate_drupal_6 */ class MigrateTermNodeTest extends MigrateDrupal6TestBase { diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php index 8d9465f61a3..7fcb764eac3 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php @@ -11,6 +11,7 @@ use Drupal\node\Entity\Node; * Upgrade taxonomy term node associations. * * @group migrate_drupal_6 + * @group #slow */ class MigrateTermNodeTranslationTest extends MigrateDrupal6TestBase { diff --git a/core/modules/toolbar/js/escapeAdmin.js b/core/modules/toolbar/js/escapeAdmin.js index 2d76991e9dc..f7956befe23 100644 --- a/core/modules/toolbar/js/escapeAdmin.js +++ b/core/modules/toolbar/js/escapeAdmin.js @@ -14,7 +14,7 @@ // loaded within an existing "workflow". if ( !pathInfo.currentPathIsAdmin && - !/destination=/.test(windowLocation.search) + !windowLocation.search.includes('destination=') ) { sessionStorage.setItem('escapeAdminPath', windowLocation); } diff --git a/core/modules/toolbar/js/views/ToolbarVisualView.js b/core/modules/toolbar/js/views/ToolbarVisualView.js index 89f472f0eaf..00bd236973f 100644 --- a/core/modules/toolbar/js/views/ToolbarVisualView.js +++ b/core/modules/toolbar/js/views/ToolbarVisualView.js @@ -210,7 +210,7 @@ // Deactivate the previous tab. $(this.model.previous('activeTab')) .removeClass('is-active') - .prop('aria-pressed', false); + .attr('aria-pressed', false); // Deactivate the previous tray. $(this.model.previous('activeTray')).removeClass('is-active'); @@ -222,7 +222,7 @@ $tab .addClass('is-active') // Mark the tab as pressed. - .prop('aria-pressed', true); + .attr('aria-pressed', true); const name = $tab.attr('data-toolbar-tray'); // Store the active tab name or remove the setting. const id = $tab.get(0).id; diff --git a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php index c315f9f6ebb..dcf0ff6d79c 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\toolbar\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; /** @@ -43,12 +44,22 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Test that it is possible to toggle the toolbar tray. - $content = $page->findLink('Content'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is open by default.'); - $page->clickLink('Manage'); - $this->assertFalse($content->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); - $page->clickLink('Manage'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); + $content_link = $page->findLink('Content'); + $manage_link = $page->find('css', '#toolbar-item-administration'); + + // Start with open tray. + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is open by default.'); + + // Click to close. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, FALSE); + $this->assertFalse($content_link->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); + + // Click to open. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); // Test toggling the toolbar tray between horizontal and vertical. $tray = $page->findById('toolbar-item-administration-tray'); @@ -87,4 +98,33 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $this->assertFalse($button->isVisible(), 'Orientation toggle from other tray is not visible'); } + /** + * Asserts that an element's `aria-pressed` attribute matches expected state. + * + * Uses `waitFor()` to pause until either the condition is met or the timeout + * of `1` second has passed. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to be tested. + * @param bool $expected + * The expected value of `aria-pressed`, as a boolean. + * + * @throws ExpectationFailedException + */ + private function waitAndAssertAriaPressedState(NodeElement $element, bool $expected): void { + $this->assertTrue( + $this + ->getSession() + ->getPage() + ->waitFor(1, function () use ($element, $expected): bool { + // Get boolean representation of `aria-pressed`. + // TRUE if `aria-pressed="true"`, FALSE otherwise. + $actual = $element->getAttribute('aria-pressed') == 'true'; + + // Exit `waitFor()` when $actual == $expected. + return $actual == $expected; + }) + ); + } + } diff --git a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js index cbba417abe3..0bed815f330 100644 --- a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js +++ b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js @@ -13,27 +13,10 @@ const userOrientationBtn = `${itemUserTray} .toolbar-toggle-orientation button`; module.exports = { '@tags': ['core'], before(browser) { - browser - .drupalInstall() - .drupalInstallModule('toolbar', true) - .drupalCreateUser({ - name: 'user', - password: '123', - permissions: [ - 'access site reports', - 'access toolbar', - 'access administration pages', - 'administer menu', - 'administer modules', - 'administer site configuration', - 'administer account settings', - 'administer software updates', - 'access content', - 'administer permissions', - 'administer users', - ], - }) - .drupalLogin({ name: 'user', password: '123' }); + browser.drupalInstall({ + setupFile: + 'core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php', + }); }, beforeEach(browser) { // Set the resolution to the default desktop resolution. Ensure the default @@ -189,7 +172,7 @@ module.exports = { browser.drupalRelativeURL('/admin'); // Don't check the visibility as stark doesn't add the .path-admin class // to the <body> required to display the button. - browser.assert.attributeContains(escapeSelector, 'href', '/user/2'); + browser.assert.attributeContains(escapeSelector, 'href', '/user/login'); }, 'Aural view test: tray orientation': (browser) => { browser.waitForElementPresent( diff --git a/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php new file mode 100644 index 00000000000..47dd0e6e50a --- /dev/null +++ b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\toolbar\Nightwatch; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\TestSite\TestSetupInterface; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; + +/** + * Sets up the site for testing the toolbar module. + */ +class ToolbarTestSetup implements TestSetupInterface { + + /** + * {@inheritdoc} + */ + public function setup(): void { + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['toolbar']); + + $role = Role::load(RoleInterface::ANONYMOUS_ID); + foreach ([ + 'access toolbar', + 'access administration pages', + 'administer modules', + 'administer site configuration', + 'administer account settings', + ] as $permission) { + $role->grantPermission($permission); + } + $role->save(); + } + +} diff --git a/core/modules/update/src/Hook/UpdateHooks.php b/core/modules/update/src/Hook/UpdateHooks.php index 2502d4bb171..6c6c57b53e5 100644 --- a/core/modules/update/src/Hook/UpdateHooks.php +++ b/core/modules/update/src/Hook/UpdateHooks.php @@ -2,6 +2,7 @@ namespace Drupal\update\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\update\UpdateManagerInterface; use Drupal\Core\Url; @@ -96,10 +97,10 @@ class UpdateHooks { } if (!empty($verbose)) { if (isset($status[$type]['severity'])) { - if ($status[$type]['severity'] == REQUIREMENT_ERROR) { + if ($status[$type]['severity'] === RequirementSeverity::Error) { \Drupal::messenger()->addError($status[$type]['description']); } - elseif ($status[$type]['severity'] == REQUIREMENT_WARNING) { + elseif ($status[$type]['severity'] === RequirementSeverity::Warning) { \Drupal::messenger()->addWarning($status[$type]['description']); } } diff --git a/core/modules/update/src/Hook/UpdateRequirements.php b/core/modules/update/src/Hook/UpdateRequirements.php index 4aa5ccc1826..2f51f205b1a 100644 --- a/core/modules/update/src/Hook/UpdateRequirements.php +++ b/core/modules/update/src/Hook/UpdateRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update\Hook; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Link; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -76,7 +77,7 @@ class UpdateRequirements { else { $requirements['update_core']['title'] = $this->t('Drupal core update status'); $requirements['update_core']['value'] = $this->t('No update data available'); - $requirements['update_core']['severity'] = REQUIREMENT_WARNING; + $requirements['update_core']['severity'] = RequirementSeverity::Warning; $requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN; $requirements['update_core']['description'] = _update_no_data(); } @@ -113,7 +114,7 @@ class UpdateRequirements { $status = $project['status']; if ($status != UpdateManagerInterface::CURRENT) { $requirement['reason'] = $status; - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; // When updates are available, append the available updates link to the // message from _update_message_text(), and format the two translated // strings together in a single paragraph. @@ -137,7 +138,7 @@ class UpdateRequirements { case UpdateManagerInterface::NOT_CURRENT: $requirement_label = $this->t('Out of date'); - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; break; case UpdateFetcherInterface::UNKNOWN: @@ -145,7 +146,7 @@ class UpdateRequirements { case UpdateFetcherInterface::NOT_FETCHED: case UpdateFetcherInterface::FETCH_PENDING: $requirement_label = $project['reason'] ?? $this->t('Can not determine status'); - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; break; default: diff --git a/core/modules/update/src/ProjectSecurityRequirement.php b/core/modules/update/src/ProjectSecurityRequirement.php index cc6fed789fe..331c65537c8 100644 --- a/core/modules/update/src/ProjectSecurityRequirement.php +++ b/core/modules/update/src/ProjectSecurityRequirement.php @@ -2,6 +2,7 @@ namespace Drupal\update; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -141,11 +142,11 @@ final class ProjectSecurityRequirement { 'Covered until @end_version', ['@end_version' => $this->securityCoverageInfo['security_coverage_end_version']] ); - $requirement['severity'] = $this->securityCoverageInfo['additional_minors_coverage'] > 1 ? REQUIREMENT_INFO : REQUIREMENT_WARNING; + $requirement['severity'] = $this->securityCoverageInfo['additional_minors_coverage'] > 1 ? RequirementSeverity::Info : RequirementSeverity::Warning; } else { $requirement['value'] = $this->t('Coverage has ended'); - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; } } return $requirement; @@ -224,7 +225,7 @@ final class ProjectSecurityRequirement { if ($this->securityCoverageInfo['security_coverage_end_date'] <= $comparable_request_date) { // Security coverage is over. $requirement['value'] = $this->t('Coverage has ended'); - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; $requirement['description']['coverage_message'] = [ '#markup' => $this->getVersionNoSecurityCoverageMessage(), '#suffix' => ' ', @@ -237,7 +238,7 @@ final class ProjectSecurityRequirement { ->format($security_coverage_end_timestamp, 'custom', $output_date_format); $translation_arguments = ['@date' => $formatted_end_date]; $requirement['value'] = $this->t('Covered until @date', $translation_arguments); - $requirement['severity'] = REQUIREMENT_INFO; + $requirement['severity'] = RequirementSeverity::Info; // 'security_coverage_ending_warn_date' will always be in the format // 'Y-m-d'. $request_date = $date_formatter->format($time->getRequestTime(), 'custom', 'Y-m-d'); @@ -246,7 +247,7 @@ final class ProjectSecurityRequirement { '#markup' => $this->t('Update to a supported version soon to continue receiving security updates.'), '#suffix' => ' ', ]; - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; } } $requirement['description']['release_cycle_link'] = ['#markup' => $this->getReleaseCycleLink()]; diff --git a/core/modules/update/update.fetch.inc b/core/modules/update/update.fetch.inc index a0d2a22e562..c8e4990d385 100644 --- a/core/modules/update/update.fetch.inc +++ b/core/modules/update/update.fetch.inc @@ -5,6 +5,7 @@ */ use Drupal\Core\Hook\Attribute\ProceduralHookScanStop; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\update\UpdateManagerInterface; /** @@ -33,7 +34,7 @@ function _update_cron_notify(): void { foreach (['core', 'contrib'] as $report_type) { $type = 'update_' . $report_type; if (isset($status[$type]['severity']) - && ($status[$type]['severity'] == REQUIREMENT_ERROR || ($notify_all && $status[$type]['reason'] == UpdateManagerInterface::NOT_CURRENT))) { + && ($status[$type]['severity'] == RequirementSeverity::Error || ($notify_all && $status[$type]['reason'] == UpdateManagerInterface::NOT_CURRENT))) { $params[$report_type] = $status[$type]['reason']; } } diff --git a/core/modules/user/src/Hook/UserRequirements.php b/core/modules/user/src/Hook/UserRequirements.php index 186ce12285f..f317ced58bc 100644 --- a/core/modules/user/src/Hook/UserRequirements.php +++ b/core/modules/user/src/Hook/UserRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\user\Hook; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -40,7 +41,7 @@ class UserRequirements { 'description' => $this->t('The anonymous user does not exist. See the <a href=":url">restore the anonymous (user ID 0) user record</a> for more information', [ ':url' => 'https://www.drupal.org/node/1029506', ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -57,7 +58,7 @@ class UserRequirements { 'description' => $this->t('Some user accounts have email addresses that differ only by case. For example, one account might have alice@example.com and another might have Alice@Example.com. See <a href=":url">Conflicting User Emails</a> for more information.', [ ':url' => 'https://www.drupal.org/node/3486109', ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js index dd3da9b8350..8e646697d83 100644 --- a/core/modules/views/js/ajax_view.js +++ b/core/modules/views/js/ajax_view.js @@ -83,7 +83,7 @@ if (queryString !== '') { // If there is a '?' in ajaxPath, clean URL are on and & should be // used to add parameters. - queryString = (/\?/.test(ajaxPath) ? '&' : '?') + queryString; + queryString = (ajaxPath.includes('?') ? '&' : '?') + queryString; } } diff --git a/core/modules/views/src/Entity/View.php b/core/modules/views/src/Entity/View.php index cd1b2a0a42e..f6bb32cec87 100644 --- a/core/modules/views/src/Entity/View.php +++ b/core/modules/views/src/Entity/View.php @@ -481,7 +481,7 @@ class View extends ConfigEntityBase implements ViewEntityInterface { * {@inheritdoc} */ public function onDependencyRemoval(array $dependencies) { - $changed = FALSE; + $changed = parent::onDependencyRemoval($dependencies); // Don't intervene if the views module is removed. if (isset($dependencies['module']) && in_array('views', $dependencies['module'])) { diff --git a/core/modules/views/src/Form/ViewsExposedForm.php b/core/modules/views/src/Form/ViewsExposedForm.php index d68b1dd5363..9f90160ff55 100644 --- a/core/modules/views/src/Form/ViewsExposedForm.php +++ b/core/modules/views/src/Form/ViewsExposedForm.php @@ -196,7 +196,6 @@ class ViewsExposedForm extends FormBase implements WorkspaceSafeFormInterface { $view->exposed_data = $values; $view->exposed_raw_input = []; - $exclude = ['submit', 'form_build_id', 'form_id', 'form_token', 'exposed_form_plugin', 'reset']; /** @var \Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase $exposed_form_plugin */ $exposed_form_plugin = $view->display_handler->getPlugin('exposed_form'); $exposed_form_plugin->exposedFormSubmit($form, $form_state, $exclude); diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index fc4a983f929..d3adc61de5a 100644 --- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php @@ -2117,13 +2117,18 @@ abstract class DisplayPluginBase extends PluginBase implements DisplayPluginInte $hasMoreRecords = !empty($this->view->pager) && $this->view->pager->hasMoreRecords(); if ($this->isMoreEnabled() && ($this->useMoreAlways() || $hasMoreRecords)) { $url = $this->getMoreUrl(); + $access = $url->access(return_as_object: TRUE); - return [ + $more_link = [ '#type' => 'more_link', '#url' => $url, '#title' => $this->useMoreText(), '#view' => $this->view, + '#access' => $access->isAllowed(), ]; + $accessCacheability = CacheableMetadata::createFromObject($access); + $accessCacheability->applyTo($more_link); + return $more_link; } } diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml new file mode 100644 index 00000000000..eb59548f17f --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml @@ -0,0 +1,37 @@ +langcode: en +status: true +dependencies: + module: + - node + - views_third_party_settings_test +third_party_settings: + views_third_party_settings_test: + example_setting: true +id: test_third_party_uninstall +label: test_third_party_uninstall +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + display_options: + access: + type: none + cache: + type: tag + exposed_form: + type: basic + pager: + type: full + query: + type: views_query + style: + type: default + row: + type: fields + display_plugin: default + display_title: Defaults + id: default + position: 0 diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml new file mode 100644 index 00000000000..0bdeeed705a --- /dev/null +++ b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml @@ -0,0 +1,7 @@ +views.view.*.third_party.views_third_party_settings_test: + type: config_entity + label: "Example settings" + mapping: + example_setting: + type: boolean + label: "Example setting" diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml new file mode 100644 index 00000000000..be975279565 --- /dev/null +++ b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml @@ -0,0 +1,8 @@ +name: 'Third Party Settings Test' +type: module +description: 'A dummy module that third party settings tests can depend on' +package: Testing +version: VERSION +dependencies: + - drupal:node + - drupal:views diff --git a/core/modules/views/tests/src/Functional/GlossaryTest.php b/core/modules/views/tests/src/Functional/GlossaryTest.php index 292f9176771..25c08d5f159 100644 --- a/core/modules/views/tests/src/Functional/GlossaryTest.php +++ b/core/modules/views/tests/src/Functional/GlossaryTest.php @@ -83,7 +83,6 @@ class GlossaryTest extends ViewTestBase { 'url', 'user.node_grants:view', 'user.permissions', - 'route', ], [ 'config:views.view.glossary', diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php index 8af887d1ef1..5aecbea3e36 100644 --- a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\views\Functional\Plugin; -use Drupal\Component\Render\FormattableMarkup; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\views\Functional\ViewTestBase; use Drupal\views\Views; @@ -317,6 +316,14 @@ class DisplayTest extends ViewTestBase { $output = $view->preview(); $output = (string) $renderer->renderRoot($output); $this->assertStringContainsString('/node?date=22&foo=bar#22', $output, 'The read more link with href "/node?date=22&foo=bar#22" was found.'); + + // Test more link isn't rendered if user doesn't have permission to the + // more link URL. + $view->display_handler->setOption('link_url', 'admin/content'); + $this->executeView($view); + $output = $view->preview(); + $output = (string) $renderer->renderRoot($output); + $this->assertStringNotContainsString('/admin/content', $output, 'The read more link with href "/admin/content" was not found.'); } /** @@ -389,8 +396,8 @@ class DisplayTest extends ViewTestBase { $errors = $view->validate(); // Check that the error messages are shown. $this->assertCount(2, $errors['default'], 'Error messages found for required relationship'); - $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Last login', '%display_name' => 'Default']), $errors['default'][0]); - $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Created', '%display_name' => 'Default']), $errors['default'][1]); + $this->assertEquals("The uid relationship used in field User: Last login is not present in the Default display.", $errors['default'][0]); + $this->assertEquals("The uid relationship used in field User: Created is not present in the Default display.", $errors['default'][1]); } /** diff --git a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php index 03488125064..e19f1414615 100644 --- a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php +++ b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php @@ -150,4 +150,38 @@ class ArgumentSummaryTest extends ViewsKernelTestBase { $this->assertStringContainsString($tags[1]->label() . ' (2)', $output); } + /** + * Tests that the active link is set correctly. + */ + public function testActiveLink(): void { + require_once $this->root . '/core/modules/views/views.theme.inc'; + + // We need at least one node. + Node::create([ + 'type' => $this->nodeType->id(), + 'title' => $this->randomMachineName(), + ])->save(); + + $view = Views::getView('test_argument_summary'); + $view->execute(); + $view->build(); + $variables = [ + 'view' => $view, + 'rows' => $view->result, + ]; + + template_preprocess_views_view_summary_unformatted($variables); + $this->assertFalse($variables['rows'][0]->active); + + template_preprocess_views_view_summary($variables); + $this->assertFalse($variables['rows'][0]->active); + + // Checks that the row with the current path is active. + \Drupal::service('path.current')->setPath('/test-argument-summary'); + template_preprocess_views_view_summary_unformatted($variables); + $this->assertTrue($variables['rows'][0]->active); + template_preprocess_views_view_summary($variables); + $this->assertTrue($variables['rows'][0]->active); + } + } diff --git a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php index 97d670634b3..14f90fd0c33 100644 --- a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php +++ b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php @@ -137,12 +137,13 @@ class ExposedFormRenderTest extends ViewsKernelTestBase { $view->save(); $this->executeView($view); + // The "type" filter should be excluded from the raw input because its + // value is "All". $expected = [ - 'type' => 'All', 'type_with_default_value' => 'article', 'multiple_types_with_default_value' => ['article' => 'article'], ]; - $this->assertSame($view->exposed_raw_input, $expected); + $this->assertSame($expected, $view->exposed_raw_input); } } diff --git a/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php new file mode 100644 index 00000000000..0f3d3eb5291 --- /dev/null +++ b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\views\Kernel; + +use Drupal\views\Entity\View; + +/** + * Tests proper removal of third-party settings from views. + * + * @group views + */ +class ThirdPartyUninstallTest extends ViewsKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['node', 'views_third_party_settings_test']; + + /** + * Views used by this test. + * + * @var array + */ + public static $testViews = ['test_third_party_uninstall']; + + /** + * {@inheritdoc} + */ + protected function setUp($import_test_views = TRUE): void { + parent::setUp($import_test_views); + + $this->installEntitySchema('user'); + $this->installSchema('user', ['users_data']); + } + + /** + * Tests removing third-party settings when a provider module is uninstalled. + */ + public function testThirdPartyUninstall(): void { + $view = View::load('test_third_party_uninstall'); + $this->assertNotEmpty($view); + $this->assertContains('views_third_party_settings_test', $view->getDependencies()['module']); + $this->assertTrue($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting')); + + \Drupal::service('module_installer')->uninstall(['views_third_party_settings_test']); + + $view = View::load('test_third_party_uninstall'); + $this->assertNotEmpty($view); + $this->assertNotContains('views_third_party_settings_test', $view->getDependencies()['module']); + $this->assertNull($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting')); + } + +} diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc index 10c29c5dbf3..04c5de5a535 100644 --- a/core/modules/views/views.theme.inc +++ b/core/modules/views/views.theme.inc @@ -253,15 +253,12 @@ function template_preprocess_views_view_summary(&$variables): void { $url_options['query'] = $view->exposed_raw_input; } + $currentPath = \Drupal::service('path.current')->getPath(); $active_urls = [ // Force system path. - Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(), - // Force system path. - Url::fromRouteMatch(\Drupal::routeMatch())->setOption('alias', TRUE)->toString(), - // Could be an alias. - Url::fromRoute('<current>')->toString(), + Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(), // Could be an alias. - Url::fromRouteMatch(\Drupal::routeMatch())->toString(), + Url::fromUserInput($currentPath)->toString(), ]; $active_urls = array_combine($active_urls, $active_urls); @@ -342,11 +339,12 @@ function template_preprocess_views_view_summary_unformatted(&$variables): void { } $count = 0; + $currentPath = \Drupal::service('path.current')->getPath(); $active_urls = [ // Force system path. - Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(), + Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(), // Could be an alias. - Url::fromRoute('<current>')->toString(), + Url::fromUserInput($currentPath)->toString(), ]; $active_urls = array_combine($active_urls, $active_urls); diff --git a/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php b/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php index e46fbcf417b..180edc868f6 100644 --- a/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php +++ b/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php @@ -124,6 +124,8 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { * An array of test data. */ public static function checkAccessProvider() { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $container = new ContainerBuilder(); $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); @@ -131,7 +133,7 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { $container->set('cache_contexts_manager', $cache_contexts_manager); \Drupal::setContainer($container); - return [ + $data = [ 'Admin view' => [ 'adminUser', 'view', @@ -275,6 +277,13 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { AccessResult::allowed()->addCacheContexts(['user.permissions']), ], ]; + + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + + return $data; } } diff --git a/core/modules/workspaces/src/Hook/EntityOperations.php b/core/modules/workspaces/src/Hook/EntityOperations.php index 08e2dc70cc8..f193795cc41 100644 --- a/core/modules/workspaces/src/Hook/EntityOperations.php +++ b/core/modules/workspaces/src/Hook/EntityOperations.php @@ -297,7 +297,7 @@ class EntityOperations { * * Alters entity forms to disallow concurrent editing in multiple workspaces. */ - #[Hook('form_alter')] + #[Hook('form_alter', order: Order::First)] public function entityFormAlter(array &$form, FormStateInterface $form_state, string $form_id): void { if (!$form_state->getFormObject() instanceof EntityFormInterface) { return; diff --git a/core/modules/workspaces/src/Hook/FormOperations.php b/core/modules/workspaces/src/Hook/FormOperations.php index 85f37458239..61b775ea3cd 100644 --- a/core/modules/workspaces/src/Hook/FormOperations.php +++ b/core/modules/workspaces/src/Hook/FormOperations.php @@ -29,20 +29,24 @@ class FormOperations { return; } - // Add a validation step for every form if we are in a workspace. - $this->addWorkspaceValidation($form); + // If a form hasn't already been marked as safe or not to submit in a + // workspace, check the generic interfaces. + if (!$form_state->has('workspace_safe')) { + $form_object = $form_state->getFormObject(); + $workspace_safe = $form_object instanceof WorkspaceSafeFormInterface + || ($form_object instanceof WorkspaceDynamicSafeFormInterface && $form_object->isWorkspaceSafeForm($form, $form_state)); - // If a form has already been marked as safe or not to submit in a - // workspace, we don't have anything else to do. - if ($form_state->has('workspace_safe')) { - return; + $form_state->set('workspace_safe', $workspace_safe); } - $form_object = $form_state->getFormObject(); - $workspace_safe = $form_object instanceof WorkspaceSafeFormInterface - || ($form_object instanceof WorkspaceDynamicSafeFormInterface && $form_object->isWorkspaceSafeForm($form, $form_state)); - - $form_state->set('workspace_safe', $workspace_safe); + // Add a validation step for every other form. + if ($form_state->get('workspace_safe') !== TRUE) { + $form['workspace_safe'] = [ + '#type' => 'value', + '#value' => FALSE, + ]; + $this->addWorkspaceValidation($form); + } } /** @@ -59,8 +63,14 @@ class FormOperations { } } - if (isset($element['#validate'])) { + if (isset($element['#submit'])) { $element['#validate'][] = [static::class, 'validateDefaultWorkspace']; + + // Ensure that the workspace validation is always shown, even when the + // form element is limiting validation errors. + if (isset($element['#limit_validation_errors']) && $element['#limit_validation_errors'] !== FALSE) { + $element['#limit_validation_errors'][] = ['workspace_safe']; + } } } @@ -68,8 +78,8 @@ class FormOperations { * Validation handler which sets a validation error for all unsupported forms. */ public static function validateDefaultWorkspace(array &$form, FormStateInterface $form_state): void { - if ($form_state->get('workspace_safe') !== TRUE) { - $form_state->setError($form, new TranslatableMarkup('This form can only be submitted in the default workspace.')); + if ($form_state->get('workspace_safe') !== TRUE && isset($form_state->getCompleteForm()['workspace_safe'])) { + $form_state->setErrorByName('workspace_safe', new TranslatableMarkup('This form can only be submitted in the default workspace.')); } } diff --git a/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php b/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php index a54148215af..d865ea82c17 100644 --- a/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php +++ b/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\workspaces\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the workspaces module. @@ -18,7 +19,7 @@ class WorkspacesRequirements implements InstallRequirementsInterface { $requirements = []; if (\Drupal::moduleHandler()->moduleExists('workspace')) { $requirements['workspace_incompatibility'] = [ - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Workspaces can not be installed when the contributed Workspace module is also installed. See the <a href=":link">upgrade path</a> page for more information on how to upgrade.', [ ':link' => 'https://www.drupal.org/node/2987783', ]), diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php new file mode 100644 index 00000000000..efd3bef34c2 --- /dev/null +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\workspaces\Functional; + +use Drupal\Tests\BrowserTestBase; +use Drupal\workspaces\Entity\Workspace; + +/** + * Tests Workspaces form validation. + * + * @group workspaces + */ +class WorkspaceFormValidationTest extends BrowserTestBase { + + use WorkspaceTestUtilities; + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'form_test', 'workspaces']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->drupalLogin($this->drupalCreateUser(['administer workspaces'])); + $this->setupWorkspaceSwitcherBlock(); + } + + /** + * Tests partial form validation through #limit_validation_errors. + */ + public function testValidateLimitErrors(): void { + $stage = Workspace::load('stage'); + $this->switchToWorkspace($stage); + + $edit = [ + 'test' => 'test1', + 'test_numeric_index[0]' => 'test2', + 'test_substring[foo]' => 'test3', + ]; + $path = 'form-test/limit-validation-errors'; + + // Submit the form by pressing all the 'Partial validate' buttons. + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate (numeric index)'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate (substring)'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + // Now test full form validation. + $this->drupalGet($path); + $this->submitForm($edit, 'Full validate'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + } + +} diff --git a/core/profiles/demo_umami/config/install/image.style.large_21_9.yml b/core/profiles/demo_umami/config/install/image.style.large_21_9.yml index 562912c5af9..5bc5971a299 100644 --- a/core/profiles/demo_umami/config/install/image.style.large_21_9.yml +++ b/core/profiles/demo_umami/config/install/image.style.large_21_9.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 622f10ec-5f36-427e-a54a-4c0b8a45d6ab: uuid: 622f10ec-5f36-427e-a54a-4c0b8a45d6ab - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml b/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml index 92a413cec5d..b6d61e4087e 100644 --- a/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml +++ b/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 20ec38fa-1f1c-422d-9fed-48ed96e3eb50: uuid: 20ec38fa-1f1c-422d-9fed-48ed96e3eb50 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml b/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml index 9163a56dcae..08e5898f094 100644 --- a/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml +++ b/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 3363b685-9a5f-4bd8-9246-1ad117054988: uuid: 3363b685-9a5f-4bd8-9246-1ad117054988 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml b/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml index 30c45680515..4fa82549294 100644 --- a/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml +++ b/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 11b52bd1-8d1a-4e61-9578-3a3a290760a2: uuid: 11b52bd1-8d1a-4e61-9578-3a3a290760a2 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml b/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml index aea54082182..8273f07fbb0 100644 --- a/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml +++ b/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml @@ -14,7 +14,7 @@ effects: anchor: center-center dd8d02b0-2ae5-4e0c-988f-730711911c49: uuid: dd8d02b0-2ae5-4e0c-988f-730711911c49 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml b/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml index 365c7ff976b..b7f0c5d619c 100644 --- a/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml +++ b/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 1d1dd894-df16-49b4-8433-0a41624ccde2: uuid: 1d1dd894-df16-49b4-8433-0a41624ccde2 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml b/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml index 4c9a7416a85..abb525feb70 100644 --- a/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml +++ b/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml @@ -14,7 +14,7 @@ effects: anchor: center-center cdd80cc9-5ecc-4040-9d83-a271c5142b7d: uuid: cdd80cc9-5ecc-4040-9d83-a271c5142b7d - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml b/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml index f1abccdbc11..37ee3cde532 100644 --- a/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml +++ b/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 622134fc-29c1-43d4-ba34-beffc0bc4e15: uuid: 622134fc-29c1-43d4-ba34-beffc0bc4e15 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml index b2d726725c2..faa0ac812df 100644 --- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml +++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 7108ef83-c308-4a0f-b877-e85d4995243b: uuid: 7108ef83-c308-4a0f-b877-e85d4995243b - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml index 5d827975098..36286bd884c 100644 --- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml +++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml @@ -14,7 +14,7 @@ effects: anchor: center-center d83dd527-8378-4a47-9b0c-8c7a753cc89a: uuid: d83dd527-8378-4a47-9b0c-8c7a753cc89a - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml index 7efcc1c57a8..006657eceb9 100644 --- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml +++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml @@ -14,7 +14,7 @@ effects: anchor: center-center d5eb26a0-f961-4ba4-9f01-94a44440b4fa: uuid: d5eb26a0-f961-4ba4-9f01-94a44440b4fa - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml index e1f05f5757a..e524f904b6c 100644 --- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml +++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 18643b2d-f1ee-4dd6-ad72-84b67023d3cf: uuid: 18643b2d-f1ee-4dd6-ad72-84b67023d3cf - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml index 478556b2a7b..2b5dd2b65c3 100644 --- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml +++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml @@ -14,7 +14,7 @@ effects: anchor: center-center d88116a5-6a1a-40ba-8a47-c635b2536de9: uuid: d88116a5-6a1a-40ba-8a47-c635b2536de9 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.small_21_9.yml b/core/profiles/demo_umami/config/install/image.style.small_21_9.yml index 448c2bbc9a0..c2d31ecc516 100644 --- a/core/profiles/demo_umami/config/install/image.style.small_21_9.yml +++ b/core/profiles/demo_umami/config/install/image.style.small_21_9.yml @@ -14,7 +14,7 @@ effects: anchor: center-center f4f34147-c147-4e51-a7e2-5334f5ddb6f7: uuid: f4f34147-c147-4e51-a7e2-5334f5ddb6f7 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.square_large.yml b/core/profiles/demo_umami/config/install/image.style.square_large.yml index c395c1d660c..67ccd1a7c98 100644 --- a/core/profiles/demo_umami/config/install/image.style.square_large.yml +++ b/core/profiles/demo_umami/config/install/image.style.square_large.yml @@ -14,7 +14,7 @@ effects: anchor: center-center 54c8c29a-0a39-4ff0-b61b-8e85cc9367a4: uuid: 54c8c29a-0a39-4ff0-b61b-8e85cc9367a4 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.square_medium.yml b/core/profiles/demo_umami/config/install/image.style.square_medium.yml index 958180a249b..1782c9ee793 100644 --- a/core/profiles/demo_umami/config/install/image.style.square_medium.yml +++ b/core/profiles/demo_umami/config/install/image.style.square_medium.yml @@ -14,7 +14,7 @@ effects: anchor: center-center b39ee1dd-ee27-4219-ba5d-e49d973f6b0f: uuid: b39ee1dd-ee27-4219-ba5d-e49d973f6b0f - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/install/image.style.square_small.yml b/core/profiles/demo_umami/config/install/image.style.square_small.yml index 38780763112..0e3db6d1512 100644 --- a/core/profiles/demo_umami/config/install/image.style.square_small.yml +++ b/core/profiles/demo_umami/config/install/image.style.square_small.yml @@ -14,7 +14,7 @@ effects: anchor: center-center dc9608c0-7510-4fbf-9474-028565687572: uuid: dc9608c0-7510-4fbf-9474-028565687572 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml b/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml index ec9723761fb..555a91d0a13 100644 --- a/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml +++ b/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml @@ -17,7 +17,7 @@ effects: upscale: false 45c1e8e2-700e-4300-b0b8-c219e91d256b: uuid: 45c1e8e2-700e-4300-b0b8-c219e91d256b - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml b/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml index 27e9f72aa34..7b350902444 100644 --- a/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml +++ b/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml @@ -17,7 +17,7 @@ effects: upscale: false ecb160c9-df96-4b0b-9ef8-8ac74dd319f8: uuid: ecb160c9-df96-4b0b-9ef8-8ac74dd319f8 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml b/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml index 177af88aacc..bb3011d86f9 100644 --- a/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml +++ b/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml @@ -17,7 +17,7 @@ effects: upscale: false 317b3f98-979a-4ee6-adec-fd24c7c7332c: uuid: 317b3f98-979a-4ee6-adec-fd24c7c7332c - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml b/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml index f941bb18648..53ab0716522 100644 --- a/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml +++ b/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml @@ -17,7 +17,7 @@ effects: upscale: false a4968ae1-83ac-420c-8e81-c571209daa44: uuid: a4968ae1-83ac-420c-8e81-c571209daa44 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/demo_umami/src/Hook/DemoUmamiRequirements.php b/core/profiles/demo_umami/src/Hook/DemoUmamiRequirements.php index 360ad0edfe8..fedab56325d 100644 --- a/core/profiles/demo_umami/src/Hook/DemoUmamiRequirements.php +++ b/core/profiles/demo_umami/src/Hook/DemoUmamiRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\demo_umami\Hook; use Drupal\Core\Extension\ProfileExtensionList; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -31,7 +32,7 @@ class DemoUmamiRequirements { 'title' => $this->t('Experimental installation profile used'), 'value' => $info['name'], 'description' => $this->t('Experimental profiles are provided for testing purposes only. Use at your own risk. To start building a new site, reinstall Drupal and choose a non-experimental profile.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; return $requirements; } diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php index 0b56bdd18c3..ceb9e19d43d 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php @@ -30,7 +30,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase { 'ScriptCount' => 1, 'ScriptBytes' => 11700, 'StylesheetCount' => 6, - 'StylesheetBytes' => 119600, + 'StylesheetBytes' => 119250, ]; $this->assertMetrics($expected, $performance_data); } @@ -69,7 +69,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase { }, 'umamiFrontAndRecipePagesEditor'); $expected = [ 'ScriptCount' => 5, - 'ScriptBytes' => 338200, + 'ScriptBytes' => 335637, 'StylesheetCount' => 5, 'StylesheetBytes' => 205700, ]; diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php index 57436a27d6f..071202256fb 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php @@ -49,7 +49,7 @@ class OpenTelemetryFrontPagePerformanceTest extends PerformanceTestBase { $this->assertSession()->pageTextContains('Umami'); $expected = [ - 'QueryCount' => 376, + 'QueryCount' => 381, 'CacheGetCount' => 471, 'CacheSetCount' => 467, 'CacheDeleteCount' => 0, @@ -119,12 +119,12 @@ class OpenTelemetryFrontPagePerformanceTest extends PerformanceTestBase { }, 'umamiFrontPageCoolCache'); $expected = [ - 'QueryCount' => 105, - 'CacheGetCount' => 230, - 'CacheSetCount' => 90, + 'QueryCount' => 112, + 'CacheGetCount' => 239, + 'CacheSetCount' => 93, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 28, + 'CacheTagLookupQueryCount' => 31, 'ScriptCount' => 1, 'ScriptBytes' => 12000, 'StylesheetCount' => 2, diff --git a/core/profiles/standard/config/optional/image.style.max_1300x1300.yml b/core/profiles/standard/config/optional/image.style.max_1300x1300.yml index fde3282498d..1801907a162 100644 --- a/core/profiles/standard/config/optional/image.style.max_1300x1300.yml +++ b/core/profiles/standard/config/optional/image.style.max_1300x1300.yml @@ -18,7 +18,7 @@ effects: upscale: false e8c9d6ba-a017-4a87-9999-7ce52e138e1d: uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/standard/config/optional/image.style.max_2600x2600.yml b/core/profiles/standard/config/optional/image.style.max_2600x2600.yml index a63e72ab6f3..ea0242ce907 100644 --- a/core/profiles/standard/config/optional/image.style.max_2600x2600.yml +++ b/core/profiles/standard/config/optional/image.style.max_2600x2600.yml @@ -18,7 +18,7 @@ effects: upscale: false 3c42f186-7beb-4dbf-b720-bff9dfeaa677: uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/standard/config/optional/image.style.max_325x325.yml b/core/profiles/standard/config/optional/image.style.max_325x325.yml index e820c8bb01d..153e87c7e87 100644 --- a/core/profiles/standard/config/optional/image.style.max_325x325.yml +++ b/core/profiles/standard/config/optional/image.style.max_325x325.yml @@ -18,7 +18,7 @@ effects: upscale: false f2b6c795-26ae-4130-aa18-aa120ea3ba98: uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/standard/config/optional/image.style.max_650x650.yml b/core/profiles/standard/config/optional/image.style.max_650x650.yml index d5beda6259f..08c969daca4 100644 --- a/core/profiles/standard/config/optional/image.style.max_650x650.yml +++ b/core/profiles/standard/config/optional/image.style.max_650x650.yml @@ -18,7 +18,7 @@ effects: upscale: false 4a2a7af8-8ea3-419d-b5f8-256d57016102: uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php index 9a7b1fbb93f..4aedf0c68a4 100644 --- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php @@ -35,9 +35,9 @@ class StandardJavascriptTest extends WebDriverTestBase { ->setPublished(); $node->save(); - // Front page: Four placeholders. + // Front page: Five placeholders. $this->drupalGet(''); - $this->assertBigPipePlaceholderReplacementCount(4); + $this->assertBigPipePlaceholderReplacementCount(5); // Front page with warm render caches: Zero placeholders. $this->drupalGet(''); diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php index 459b72d59ca..f3a3196fab6 100644 --- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php @@ -10,6 +10,8 @@ use Drupal\Tests\PerformanceData; use Drupal\node\NodeInterface; use Drupal\user\UserInterface; +// cSpell:ignore mlid + /** * Tests the performance of basic functionality in the standard profile. * @@ -88,6 +90,7 @@ class StandardPerformanceTest extends PerformanceTestBase { $expected_queries = [ 'SELECT "base_table"."id" AS "id", "base_table"."path" AS "path", "base_table"."alias" AS "alias", "base_table"."langcode" AS "langcode" FROM "path_alias" "base_table" WHERE ("base_table"."status" = 1) AND ("base_table"."alias" LIKE "/node" ESCAPE ' . "'\\\\'" . ') AND ("base_table"."langcode" IN ("en", "und")) ORDER BY "base_table"."langcode" ASC, "base_table"."id" DESC', 'SELECT "name", "route", "fit" FROM "router" WHERE "pattern_outline" IN ( "/node" ) AND "number_parts" >= 1', + 'SELECT 1 AS "expression" FROM "path_alias" "base_table" WHERE ("base_table"."status" = 1) AND ("base_table"."path" LIKE "/rss.xml%" ESCAPE ' . "'\\\\'" . ') LIMIT 1 OFFSET 0', 'SELECT COUNT(*) AS "expression" FROM (SELECT 1 AS "expression" FROM "node_field_data" "node_field_data" WHERE ("node_field_data"."promote" = 1) AND ("node_field_data"."status" = 1)) "subquery"', 'SELECT "node_field_data"."sticky" AS "node_field_data_sticky", "node_field_data"."created" AS "node_field_data_created", "node_field_data"."nid" AS "nid" FROM "node_field_data" "node_field_data" WHERE ("node_field_data"."promote" = 1) AND ("node_field_data"."status" = 1) ORDER BY "node_field_data_sticky" DESC, "node_field_data_created" DESC LIMIT 10 OFFSET 0', 'SELECT "revision"."vid" AS "vid", "revision"."langcode" AS "langcode", "revision"."revision_uid" AS "revision_uid", "revision"."revision_timestamp" AS "revision_timestamp", "revision"."revision_log" AS "revision_log", "revision"."revision_default" AS "revision_default", "base"."nid" AS "nid", "base"."type" AS "type", "base"."uuid" AS "uuid", CASE "base"."vid" WHEN "revision"."vid" THEN 1 ELSE 0 END AS "isDefaultRevision" FROM "node" "base" INNER JOIN "node_revision" "revision" ON "revision"."vid" = "base"."vid" WHERE "base"."nid" IN (1)', @@ -113,7 +116,12 @@ class StandardPerformanceTest extends PerformanceTestBase { 'SELECT "name", "data" FROM "config" WHERE "collection" = "" AND "name" IN ( "user.role.authenticated" )', 'SELECT "name", "value" FROM "key_value" WHERE "name" IN ( "theme:stark" ) AND "collection" = "config.entity.key_store.block"', 'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "view.frontpage.page_1") AND ("route_param_key" = "view_id=frontpage&display_id=page_1") AND ("menu_name" = "main") ORDER BY "depth" ASC, "weight" ASC, "id" ASC', + 'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "<front>") AND ("route_param_key" = "") AND ("menu_name" = "main") ORDER BY "depth" ASC, "weight" ASC, "id" ASC', + 'SELECT "menu_tree"."p1" AS "p1", "menu_tree"."p2" AS "p2", "menu_tree"."p3" AS "p3", "menu_tree"."p4" AS "p4", "menu_tree"."p5" AS "p5", "menu_tree"."p6" AS "p6", "menu_tree"."p7" AS "p7", "menu_tree"."p8" AS "p8", "menu_tree"."p9" AS "p9" FROM "menu_tree" "menu_tree" WHERE "id" = "standard.front_page"', + 'SELECT "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE "mlid" IN ("5") ORDER BY "depth" DESC', 'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "view.frontpage.page_1") AND ("route_param_key" = "view_id=frontpage&display_id=page_1") AND ("menu_name" = "account") ORDER BY "depth" ASC, "weight" ASC, "id" ASC', + 'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "<front>") AND ("route_param_key" = "") AND ("menu_name" = "account") ORDER BY "depth" ASC, "weight" ASC, "id" ASC', + 'SELECT "menu_tree".* FROM "menu_tree" "menu_tree" WHERE ("menu_name" = "main") AND ("depth" <= 2) ORDER BY "p1" ASC, "p2" ASC, "p3" ASC, "p4" ASC, "p5" ASC, "p6" ASC, "p7" ASC, "p8" ASC, "p9" ASC', 'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry", "LOCK_ID", "EXPIRE")', 'DELETE FROM "semaphore" WHERE ("name" = "theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")', 'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("library_info:stark:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")', @@ -126,7 +134,7 @@ class StandardPerformanceTest extends PerformanceTestBase { $recorded_queries = $performance_data->getQueries(); $this->assertSame($expected_queries, $recorded_queries); $expected = [ - 'QueryCount' => 36, + 'QueryCount' => 42, 'CacheGetCount' => 100, 'CacheGetCountByBin' => [ 'page' => 1, @@ -135,15 +143,15 @@ class StandardPerformanceTest extends PerformanceTestBase { 'discovery' => 38, 'bootstrap' => 8, 'dynamic_page_cache' => 1, - 'render' => 14, + 'render' => 13, 'default' => 5, 'entity' => 2, - 'menu' => 2, + 'menu' => 3, ], - 'CacheSetCount' => 45, + 'CacheSetCount' => 47, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 17, + 'CacheTagLookupQueryCount' => 16, 'CacheTagGroupedLookups' => [ [ 'route_match', @@ -175,7 +183,6 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:block.block.stark_messages'], ['config:block.block.stark_help'], ['config:block.block.stark_powered'], - ['config:block.block.stark_syndicate'], [ 'config:block.block.stark_account_menu', 'config:block.block.stark_breadcrumbs', @@ -188,8 +195,8 @@ class StandardPerformanceTest extends PerformanceTestBase { 'config:block_list', 'http_response', ], - ['config:system.menu.main'], ['config:system.menu.account'], + ['config:system.menu.main'], ['config:user.role.anonymous'], ], 'StylesheetCount' => 1, @@ -227,11 +234,11 @@ class StandardPerformanceTest extends PerformanceTestBase { $this->assertSame($expected_queries, $recorded_queries); $expected = [ 'QueryCount' => 10, - 'CacheGetCount' => 72, + 'CacheGetCount' => 71, 'CacheSetCount' => 16, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'CacheTagGroupedLookups' => [ [ 'route_match', @@ -260,7 +267,6 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:block.block.stark_messages'], ['config:block.block.stark_help'], ['config:block.block.stark_powered'], - ['config:block.block.stark_syndicate'], [ 'config:block.block.stark_account_menu', 'config:block.block.stark_breadcrumbs', @@ -278,7 +284,7 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:user.role.anonymous'], ], 'StylesheetCount' => 1, - 'StylesheetBytes' => 1750, + 'StylesheetBytes' => 1550, ]; $this->assertMetrics($expected, $performance_data); @@ -309,11 +315,11 @@ class StandardPerformanceTest extends PerformanceTestBase { $this->assertSame($expected_queries, $recorded_queries); $expected = [ 'QueryCount' => 14, - 'CacheGetCount' => 57, + 'CacheGetCount' => 56, 'CacheSetCount' => 17, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 13, + 'CacheTagLookupQueryCount' => 12, 'StylesheetCount' => 1, 'StylesheetBytes' => 1800, ]; @@ -368,11 +374,11 @@ class StandardPerformanceTest extends PerformanceTestBase { 'StylesheetBytes' => 1429, 'StylesheetCount' => 1, 'QueryCount' => 17, - 'CacheGetCount' => 69, + 'CacheGetCount' => 68, 'CacheSetCount' => 1, 'CacheDeleteCount' => 1, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'CacheTagGroupedLookups' => [ // Form submission and login. [ @@ -414,7 +420,6 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:block.block.stark_messages'], ['config:block.block.stark_help'], ['config:block.block.stark_powered'], - ['config:block.block.stark_syndicate'], ['config:block.block.stark_main_menu'], [ 'config:block.block.stark_account_menu', diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php index 6b1ae846acf..6b31dcaf0e2 100644 --- a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php +++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php @@ -192,7 +192,7 @@ trait StandardTestTrait { foreach (ImageStyle::loadMultiple() as $style) { $effects = $style->getEffects()->getInstanceIds(); $last = $style->getEffects()->get(end($effects)); - $this->assertSame('image_convert', $last->getConfiguration()['id']); + $this->assertSame('image_convert_avif', $last->getConfiguration()['id']); $this->assertSame('webp', $last->getConfiguration()['data']['extension']); } diff --git a/core/profiles/tests/profile_install_requirements/src/Install/Requirements/ProfileInstallRequirementsRequirements.php b/core/profiles/tests/profile_install_requirements/src/Install/Requirements/ProfileInstallRequirementsRequirements.php index 9a010b930f7..76bbdec1882 100644 --- a/core/profiles/tests/profile_install_requirements/src/Install/Requirements/ProfileInstallRequirementsRequirements.php +++ b/core/profiles/tests/profile_install_requirements/src/Install/Requirements/ProfileInstallRequirementsRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\profile_install_requirements\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Provides method for checking requirements during install time. @@ -17,7 +18,7 @@ class ProfileInstallRequirementsRequirements implements InstallRequirementsInter public static function getRequirements(): array { $requirements['testing_requirements'] = [ 'title' => t('Testing requirements'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Testing requirements failed requirements.'), ]; diff --git a/core/profiles/tests/testing_requirements/src/Install/Requirements/TestingRequirementsRequirements.php b/core/profiles/tests/testing_requirements/src/Install/Requirements/TestingRequirementsRequirements.php index 71165b1c8db..e88c27c3086 100644 --- a/core/profiles/tests/testing_requirements/src/Install/Requirements/TestingRequirementsRequirements.php +++ b/core/profiles/tests/testing_requirements/src/Install/Requirements/TestingRequirementsRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\testing_requirements\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the testing_requirements module. @@ -18,7 +19,7 @@ class TestingRequirementsRequirements implements InstallRequirementsInterface { $requirements = []; $requirements['testing_requirements'] = [ 'title' => t('Testing requirements'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Testing requirements failed requirements.'), ]; diff --git a/core/recipes/standard/recipe.yml b/core/recipes/standard/recipe.yml index 4a88f8a9b69..12e764f0ff5 100644 --- a/core/recipes/standard/recipe.yml +++ b/core/recipes/standard/recipe.yml @@ -59,7 +59,6 @@ config: - block.block.olivero_help - block.block.olivero_search_form_narrow - block.block.olivero_search_form_wide - - block.block.olivero_syndicate user: - core.entity_view_mode.user.compact - search.page.user_search diff --git a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml index 2bcee4cda93..eb7e5751011 100644 --- a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml +++ b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml @@ -16,7 +16,7 @@ effects: upscale: false e8c9d6ba-a017-4a87-9999-7ce52e138e1d: uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml index 02d0d777fa7..612747fb34b 100644 --- a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml +++ b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml @@ -16,7 +16,7 @@ effects: upscale: false 3c42f186-7beb-4dbf-b720-bff9dfeaa677: uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml index 208d6f62189..45b79210c2a 100644 --- a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml +++ b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml @@ -16,7 +16,7 @@ effects: upscale: false f2b6c795-26ae-4130-aa18-aa120ea3ba98: uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml index c92f4347e2f..d64b225973f 100644 --- a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml +++ b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml @@ -16,7 +16,7 @@ effects: upscale: false 4a2a7af8-8ea3-419d-b5f8-256d57016102: uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh index 4545a00cc3a..96f16eed9c7 100755 --- a/core/scripts/run-tests.sh +++ b/core/scripts/run-tests.sh @@ -17,14 +17,13 @@ use Drupal\Component\Utility\Timer; use Drupal\Core\Composer\Composer; use Drupal\Core\Database\Database; use Drupal\Core\Test\EnvironmentCleaner; +use Drupal\Core\Test\PhpUnitTestDiscovery; use Drupal\Core\Test\PhpUnitTestRunner; use Drupal\Core\Test\SimpletestTestRunResultsStorage; -use Drupal\Core\Test\RunTests\TestFileParser; use Drupal\Core\Test\TestDatabase; use Drupal\Core\Test\TestRun; use Drupal\Core\Test\TestRunnerKernel; use Drupal\Core\Test\TestRunResultsStorageInterface; -use Drupal\Core\Test\TestDiscovery; use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\KernelTests\KernelTestBase; @@ -49,6 +48,10 @@ const SIMPLETEST_SCRIPT_COLOR_EXCEPTION = 33; const SIMPLETEST_SCRIPT_COLOR_YELLOW = 33; // A refreshing cyan. const SIMPLETEST_SCRIPT_COLOR_CYAN = 36; +// A fainting gray. +const SIMPLETEST_SCRIPT_COLOR_GRAY = 90; +// A notable white. +const SIMPLETEST_SCRIPT_COLOR_BRIGHT_WHITE = "1;97"; // Restricting the chunk of queries prevents memory exhaustion. const SIMPLETEST_SCRIPT_SQLITE_VARIABLE_LIMIT = 350; @@ -86,12 +89,12 @@ if ($args['list']) { // Display all available tests organized by one @group annotation. echo "\nAvailable test groups & classes\n"; echo "-------------------------------\n\n"; - $test_discovery = new TestDiscovery( - \Drupal::root(), - \Drupal::service('class_loader') - ); + $test_discovery = new PhpUnitTestDiscovery(\Drupal::root() . \DIRECTORY_SEPARATOR . 'core'); try { $groups = $test_discovery->getTestClasses($args['module']); + foreach ($test_discovery->getWarnings() as $warning) { + simpletest_script_print($warning . "\n", SIMPLETEST_SCRIPT_COLOR_EXCEPTION); + } } catch (Exception $e) { error_log((string) $e); @@ -119,11 +122,8 @@ if ($args['list']) { // @see https://www.drupal.org/node/2569585 if ($args['list-files'] || $args['list-files-json']) { // List all files which could be run as tests. - $test_discovery = new TestDiscovery( - \Drupal::root(), - \Drupal::service('class_loader') - ); - // TestDiscovery::findAllClassFiles() gives us a classmap similar to a + $test_discovery = new PhpUnitTestDiscovery(\Drupal::root() . \DIRECTORY_SEPARATOR . 'core'); + // PhpUnitTestDiscovery::findAllClassFiles() gives us a classmap similar to a // Composer 'classmap' array. $test_classes = $test_discovery->findAllClassFiles(); // JSON output is the easiest. @@ -177,6 +177,15 @@ if (!Composer::upgradePHPUnitCheck(Version::id())) { exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); } +echo "\n"; +echo "Drupal test run\n\n"; +echo sprintf("Drupal Version: %s\n", \Drupal::VERSION); +echo sprintf("PHP Version: %s\n", \PHP_VERSION); +echo sprintf("PHP Binary: %s\n", $php ?? getenv('_')); +echo sprintf("PHPUnit Version: %s\n", Version::id()); +echo "-------------------------------\n"; +echo "\n"; + $test_list = simpletest_script_get_test_list(); // Try to allocate unlimited time to run the tests. @@ -355,6 +364,11 @@ All arguments are long options. The index of the job in the job set. + --debug-discovery + + If provided, dumps detailed information on the tests selected + for execution, before the execution starts. + <test1>[,<test2>[,<test3> ...]] One or more tests to be run. By default, these are interpreted @@ -427,6 +441,7 @@ function simpletest_script_parse_args() { 'non-html' => FALSE, 'ci-parallel-node-index' => 1, 'ci-parallel-node-total' => 1, + 'debug-discovery' => FALSE, ]; // Override with set values. @@ -919,17 +934,15 @@ function simpletest_script_command(TestRun $test_run, string $test_class): array function simpletest_script_get_test_list() { global $args; - $test_discovery = new TestDiscovery( - \Drupal::root(), - \Drupal::service('class_loader') - ); - $types_processed = empty($args['types']); + $test_discovery = new PhpUnitTestDiscovery(\Drupal::root() . \DIRECTORY_SEPARATOR . 'core'); $test_list = []; $slow_tests = []; if ($args['all'] || $args['module'] || $args['directory']) { try { $groups = $test_discovery->getTestClasses($args['module'], $args['types'], $args['directory']); - $types_processed = TRUE; + foreach ($test_discovery->getWarnings() as $warning) { + simpletest_script_print($warning . "\n", SIMPLETEST_SCRIPT_COLOR_EXCEPTION); + } } catch (Exception $e) { echo (string) $e; @@ -938,30 +951,34 @@ function simpletest_script_get_test_list() { // Ensure that tests marked explicitly as @group #slow are run at the // beginning of each job. if (key($groups) === '#slow') { - $slow_tests = array_keys(array_shift($groups)); + $slow_tests = array_shift($groups); } $not_slow_tests = []; foreach ($groups as $group => $tests) { - $not_slow_tests = array_merge($not_slow_tests, array_keys($tests)); + $not_slow_tests = array_merge($not_slow_tests, $tests); } // Filter slow tests out of the not slow tests and ensure a unique list // since tests may appear in more than one group. - $not_slow_tests = array_unique(array_diff($not_slow_tests, $slow_tests)); + $not_slow_tests = array_diff_key($not_slow_tests, $slow_tests); // If the tests are not being run in parallel, then ensure slow tests run // all together first. if ((int) $args['ci-parallel-node-total'] <= 1 ) { sort_tests_by_type_and_methods($slow_tests); sort_tests_by_type_and_methods($not_slow_tests); - $test_list = array_merge($slow_tests, $not_slow_tests); + $all_tests_list = array_merge($slow_tests, $not_slow_tests); + assign_tests_sequence($all_tests_list); + dump_tests_sequence($all_tests_list, $args); + $test_list = array_keys($all_tests_list); } else { - // Sort all tests by the number of public methods on the test class. - // This is a proxy for the approximate time taken to run the test, - // which is used in combination with @group #slow to start the slowest tests - // first and distribute tests between test runners. + // Sort all tests by the number of test cases on the test class. + // This is used in combination with @group #slow to start the slowest + // tests first and distribute tests between test runners. sort_tests_by_public_method_count($slow_tests); sort_tests_by_public_method_count($not_slow_tests); + $all_tests_list = array_merge($slow_tests, $not_slow_tests); + assign_tests_sequence($all_tests_list); // Now set up a bin per test runner. $bin_count = (int) $args['ci-parallel-node-total']; @@ -975,6 +992,8 @@ function simpletest_script_get_test_list() { $binned_other_tests = place_tests_into_bins($not_slow_tests, $bin_count); $other_tests_for_job = $binned_other_tests[$args['ci-parallel-node-index'] - 1]; $test_list = array_merge($slow_tests_for_job, $other_tests_for_job); + dump_bin_tests_sequence($args['ci-parallel-node-index'], $all_tests_list, $test_list, $args); + $test_list = array_keys($test_list); } } else { @@ -988,6 +1007,9 @@ function simpletest_script_get_test_list() { else { try { $groups = $test_discovery->getTestClasses(NULL, $args['types']); + foreach ($test_discovery->getWarnings() as $warning) { + simpletest_script_print($warning . "\n", SIMPLETEST_SCRIPT_COLOR_EXCEPTION); + } } catch (Exception $e) { echo (string) $e; @@ -1005,19 +1027,24 @@ function simpletest_script_get_test_list() { } elseif ($args['file']) { // Extract test case class names from specified files. - $parser = new TestFileParser(); foreach ($args['test_names'] as $file) { - if (!file_exists($file)) { + if (!file_exists($file) || is_dir($file)) { simpletest_script_print_error('File not found: ' . $file); exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); } - $test_list = array_merge($test_list, $parser->getTestListFromFile($file)); + $fileTests = current($test_discovery->getTestClasses(NULL, [], $file)); + $test_list = array_merge($test_list, $fileTests); } + assign_tests_sequence($test_list); + dump_tests_sequence($test_list, $args); + $test_list = array_keys($test_list); } else { try { $groups = $test_discovery->getTestClasses(NULL, $args['types']); - $types_processed = TRUE; + foreach ($test_discovery->getWarnings() as $warning) { + simpletest_script_print($warning . "\n", SIMPLETEST_SCRIPT_COLOR_EXCEPTION); + } } catch (Exception $e) { echo (string) $e; @@ -1034,22 +1061,15 @@ function simpletest_script_get_test_list() { } // Merge the tests from the groups together. foreach ($args['test_names'] as $group_name) { - $test_list = array_merge($test_list, array_keys($groups[$group_name])); + $test_list = array_merge($test_list, $groups[$group_name]); } + assign_tests_sequence($test_list); + dump_tests_sequence($test_list, $args); // Ensure our list of tests contains only one entry for each test. - $test_list = array_unique($test_list); + $test_list = array_keys($test_list); } } - // If the test list creation does not automatically limit by test type then - // we need to do so here. - if (!$types_processed) { - $test_list = array_filter($test_list, function ($test_class) use ($args) { - $test_info = TestDiscovery::getTestInfo($test_class); - return in_array($test_info['type'], $args['types'], TRUE); - }); - } - if (empty($test_list)) { simpletest_script_print_error('No valid tests were specified.'); exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); @@ -1062,11 +1082,11 @@ function simpletest_script_get_test_list() { * Sort tests by test type and number of public methods. */ function sort_tests_by_type_and_methods(array &$tests): void { - usort($tests, function ($a, $b) { - if (get_test_type_weight($a) === get_test_type_weight($b)) { - return get_test_class_method_count($b) <=> get_test_class_method_count($a); + uasort($tests, function ($a, $b) { + if (get_test_type_weight($a['name']) === get_test_type_weight($b['name'])) { + return $b['tests_count'] <=> $a['tests_count']; } - return get_test_type_weight($b) <=> get_test_type_weight($a); + return get_test_type_weight($b['name']) <=> get_test_type_weight($a['name']); }); } @@ -1083,8 +1103,8 @@ function sort_tests_by_type_and_methods(array &$tests): void { * An array of test class names. */ function sort_tests_by_public_method_count(array &$tests): void { - usort($tests, function ($a, $b) { - return get_test_class_method_count($b) <=> get_test_class_method_count($a); + uasort($tests, function ($a, $b) { + return $b['tests_count'] <=> $a['tests_count']; }); } @@ -1105,28 +1125,46 @@ function get_test_type_weight(string $class): int { } /** - * Get an approximate test method count for a test class. + * Assigns the test sequence. * - * @param string $class - * The test class name. + * @param array $tests + * The array of test class info. */ -function get_test_class_method_count(string $class): int { - $reflection = new \ReflectionClass($class); - $count = 0; - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - // If a method uses a dataProvider, increase the count by 20 since data - // providers result in a single method running multiple times. - $comments = $method->getDocComment(); - preg_match_all('#@(.*?)\n#s', $comments, $annotations); - foreach ($annotations[1] as $annotation) { - if (str_starts_with($annotation, 'dataProvider')) { - $count = $count + 20; - continue; - } - } - $count++; +function assign_tests_sequence(array &$tests): void { + $i = 0; + foreach ($tests as &$testInfo) { + $testInfo['sequence'] = ++$i; } - return $count; +} + +/** + * Dumps the list of tests in order of execution after sorting. + * + * @param array $tests + * The array of test class info. + * @param array $args + * The command line arguments. + */ +function dump_tests_sequence(array $tests, array $args): void { + if ($args['debug-discovery'] === FALSE) { + return; + } + echo "Test execution sequence\n"; + echo "-----------------------\n\n"; + echo " Seq Slow? Group Cnt Class\n"; + echo "-----------------------------------------\n"; + $i = 0; + foreach ($tests as $testInfo) { + echo sprintf( + "%4d %5s %15s %4d %s\n", + $testInfo['sequence'], + in_array('#slow', $testInfo['groups']) ? '#slow' : '', + trim_with_ellipsis($testInfo['group'], 15, \STR_PAD_RIGHT), + $testInfo['tests_count'], + trim_with_ellipsis($testInfo['name'], 60, \STR_PAD_LEFT), + ); + } + echo "-----------------------------------------\n\n"; } /** @@ -1149,13 +1187,51 @@ function place_tests_into_bins(array $tests, int $bin_count) { // Create a bin corresponding to each parallel test job. $bins = array_fill(0, $bin_count, []); // Go through each test and add them to one bin at a time. + $i = 0; foreach ($tests as $key => $test) { - $bins[($key % $bin_count)][] = $test; + $bins[($i++ % $bin_count)][$key] = $test; } return $bins; } /** + * Dumps the list of tests in order of execution for a bin. + * + * @param int $bin + * The bin. + * @param array $allTests + * The list of all test classes discovered. + * @param array $tests + * The list of test class to run for this bin. + * @param array $args + * The command line arguments. + */ +function dump_bin_tests_sequence(int $bin, array $allTests, array $tests, array $args): void { + if ($args['debug-discovery'] === FALSE) { + return; + } + echo "Test execution sequence. "; + echo "Tests marked *** will be executed in this PARALLEL BIN #{$bin}.\n"; + echo "-------------------------------------------------------------------------------------\n\n"; + echo "Bin Seq Slow? Group Cnt Class\n"; + echo "--------------------------------------------\n"; + foreach ($allTests as $testInfo) { + $inBin = isset($tests[$testInfo['name']]); + $message = sprintf( + "%s %4d %5s %15s %4d %s\n", + $inBin ? "***" : " ", + $testInfo['sequence'], + in_array('#slow', $testInfo['groups']) ? '#slow' : '', + trim_with_ellipsis($testInfo['group'], 15, \STR_PAD_RIGHT), + $testInfo['tests_count'], + trim_with_ellipsis($testInfo['name'], 60, \STR_PAD_LEFT), + ); + simpletest_script_print($message, $inBin ? SIMPLETEST_SCRIPT_COLOR_BRIGHT_WHITE : SIMPLETEST_SCRIPT_COLOR_GRAY); + } + echo "-------------------------------------------------\n\n"; +} + +/** * Initialize the reporter. */ function simpletest_script_reporter_init(): void { @@ -1170,12 +1246,6 @@ function simpletest_script_reporter_init(): void { 'debug' => 'Log', ]; - echo "\n"; - echo "Drupal test run\n"; - echo "Using PHP Binary: $php\n"; - echo "---------------\n"; - echo "\n"; - // Tell the user about what tests are to be run. if ($args['all']) { echo "All tests will run.\n\n"; @@ -1378,7 +1448,7 @@ function simpletest_script_reporter_display_results(TestRunResultsStorageInterfa function simpletest_script_format_result($result): void { global $args, $results_map, $color; - $summary = sprintf("%-9.9s %9.3fs %-80.80s\n", $results_map[$result->status], $result->time, trim_with_ellipsis($result->function, 80, STR_PAD_LEFT)); + $summary = sprintf("%-9.9s %9.3fs %s\n", $results_map[$result->status], $result->time, trim_with_ellipsis($result->function, 80, STR_PAD_LEFT)); simpletest_script_print($summary, simpletest_script_color_code($result->status)); @@ -1540,12 +1610,12 @@ function simpletest_script_load_messages_by_test_id(TestRunResultsStorageInterfa */ function trim_with_ellipsis(string $input, int $length, int $side): string { if (strlen($input) < $length) { - return str_pad($input, $length, ' ', STR_PAD_RIGHT); + return str_pad($input, $length, ' ', \STR_PAD_RIGHT); } elseif (strlen($input) > $length) { return match($side) { - STR_PAD_RIGHT => substr($input, 0, $length - 3) . '...', - default => '...' . substr($input, -$length + 3), + \STR_PAD_RIGHT => substr($input, 0, $length - 1) . '…', + default => '…' . substr($input, -$length + 1), }; } return $input; diff --git a/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php index e92c7546884..0e0912109e8 100644 --- a/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php +++ b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php @@ -333,8 +333,8 @@ abstract class BuildTestBase extends TestCase { public function executeCommand($command_line, $working_dir = NULL) { $this->commandProcess = Process::fromShellCommandline($command_line); $this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir)) - ->setTimeout(300) - ->setIdleTimeout(300); + ->setTimeout(360) + ->setIdleTimeout(360); $this->commandProcess->run(); return $this->commandProcess; } diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php index e775b358550..e5901e52fa4 100644 --- a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Entity; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Entity\RevisionLogInterface; use Drupal\entity_test\Entity\EntityTestRev; use Drupal\entity_test\Entity\EntityTestRevPub; @@ -247,7 +246,7 @@ class RevisionDeleteFormTest extends BrowserTestBase { * * @covers ::submitForm */ - protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, string $expectedLog, string $expectedMessage, $expectedDestination): void { + protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, array $expectedLog, string $expectedMessage, $expectedDestination): void { if (count($permissions) > 0) { $this->drupalLogin($this->createUser($permissions)); } @@ -297,7 +296,9 @@ class RevisionDeleteFormTest extends BrowserTestBase { // Logger log. $logs = $this->getLogs($entity->getEntityType()->getProvider()); - $this->assertEquals([0 => $expectedLog], $logs); + $this->assertCount(1, $logs); + $this->assertEquals("@type: deleted %title revision %revision.", $logs[0]->message); + $this->assertEquals($expectedLog, unserialize($logs[0]->variables)); // Messenger message. $this->assertSession()->pageTextContains($expectedMessage); \Drupal::database()->delete('watchdog')->execute(); @@ -314,7 +315,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_rev', 'view all revisions, delete revision', 2, - 'entity_test_rev: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view all revisions, delete revision', + '%revision' => '1', + ], 'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.', '/entity_test_rev/1/revisions', ]; @@ -324,7 +329,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_rev', 'view, view all revisions, delete revision', 2, - 'entity_test_rev: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, view all revisions, delete revision', + '%revision' => '3', + ], 'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.', '/entity_test_rev/2/revisions', ]; @@ -334,7 +343,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_revlog', 'view all revisions, delete revision', 2, - 'entity_test_revlog: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view all revisions, delete revision', + '%revision' => '1', + ], 'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.', '/entity_test_revlog/1/revisions', ]; @@ -344,7 +357,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { 'entity_test_revlog', 'view, view all revisions, delete revision', 2, - 'entity_test_revlog: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, view all revisions, delete revision', + '%revision' => '3', + ], 'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.', '/entity_test_revlog/2/revisions', ]; @@ -362,14 +379,11 @@ class RevisionDeleteFormTest extends BrowserTestBase { * Watchdog entries. */ protected function getLogs(string $channel): array { - $logs = \Drupal::database()->select('watchdog') + return \Drupal::database()->select('watchdog') ->fields('watchdog') ->condition('type', $channel) ->execute() ->fetchAll(); - return array_map(function (object $log) { - return (string) new FormattableMarkup($log->message, unserialize($log->variables)); - }, $logs); } } diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php index c6ff4629fd9..8ca59b6100e 100644 --- a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Entity; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Entity\RevisionLogInterface; use Drupal\entity_test\Entity\EntityTestRev; use Drupal\entity_test\Entity\EntityTestRevPub; @@ -192,7 +191,7 @@ class RevisionRevertFormTest extends BrowserTestBase { * @covers ::submitForm * @dataProvider providerSubmitForm */ - public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, string $expectedLog, string $expectedMessage, string $expectedDestination): void { + public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, array $expectedLog, string $expectedMessage, string $expectedDestination): void { if (count($permissions) > 0) { $this->drupalLogin($this->createUser($permissions)); } @@ -231,7 +230,10 @@ class RevisionRevertFormTest extends BrowserTestBase { // Logger log. $logs = $this->getLogs($entity->getEntityType()->getProvider()); - $this->assertEquals([0 => $expectedLog], $logs); + $this->assertCount(1, $logs); + $this->assertEquals('@type: reverted %title revision %revision.', $logs[0]->message); + $this->assertEquals($expectedLog, unserialize($logs[0]->variables)); + // Messenger message. $this->assertSession()->pageTextContains($expectedMessage); } @@ -246,7 +248,11 @@ class RevisionRevertFormTest extends BrowserTestBase { ['view test entity'], 'entity_test_rev', 'view, revert', - 'entity_test_rev: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, revert', + '%revision' => '1', + ], 'Entity Test Bundle view, revert has been reverted.', '/entity_test_rev/manage/1', ]; @@ -255,7 +261,11 @@ class RevisionRevertFormTest extends BrowserTestBase { ['view test entity'], 'entity_test_rev', 'view, view all revisions, revert', - 'entity_test_rev: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_rev', + '%title' => 'view, view all revisions, revert', + '%revision' => '1', + ], 'Entity Test Bundle view, view all revisions, revert has been reverted.', '/entity_test_rev/1/revisions', ]; @@ -264,7 +274,11 @@ class RevisionRevertFormTest extends BrowserTestBase { [], 'entity_test_revlog', 'view, revert', - 'entity_test_revlog: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, revert', + '%revision' => '1', + ], 'Test entity - revisions log view, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.', '/entity_test_revlog/manage/1', ]; @@ -273,7 +287,11 @@ class RevisionRevertFormTest extends BrowserTestBase { [], 'entity_test_revlog', 'view, view all revisions, revert', - 'entity_test_revlog: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + [ + '@type' => 'entity_test_revlog', + '%title' => 'view, view all revisions, revert', + '%revision' => '1', + ], 'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.', '/entity_test_revlog/1/revisions', ]; @@ -347,14 +365,11 @@ class RevisionRevertFormTest extends BrowserTestBase { * Watchdog entries. */ protected function getLogs(string $channel): array { - $logs = \Drupal::database()->select('watchdog') + return \Drupal::database()->select('watchdog') ->fields('watchdog') ->condition('type', $channel) ->execute() ->fetchAll(); - return array_map(function (object $log) { - return (string) new FormattableMarkup($log->message, unserialize($log->variables)); - }, $logs); } /** diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php index 3bd0fe48af7..d33d7c4942a 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php @@ -63,25 +63,20 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase { // Assert that in the settings.php the database connection array has the // correct values set. - $contents = file_get_contents($this->container->getParameter('app.root') . '/' . $this->siteDirectory . '/settings.php'); - $this->assertStringContainsString("'namespace' => 'Drupal\\\\driver_test\\\\Driver\\\\Database\\\\{$this->testDriverName}',", $contents); - $this->assertStringContainsString("'driver' => '{$this->testDriverName}',", $contents); - $this->assertStringContainsString("'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/',", $contents); - - $dependencies = "'dependencies' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'mysql' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'namespace' => 'Drupal\\\\mysql'," . PHP_EOL . - " 'autoload' => 'core/modules/mysql/src/'," . PHP_EOL . - " )," . PHP_EOL . - " 'pgsql' => " . PHP_EOL . - " array (" . PHP_EOL . - " 'namespace' => 'Drupal\\\\pgsql'," . PHP_EOL . - " 'autoload' => 'core/modules/pgsql/src/'," . PHP_EOL . - " )," . PHP_EOL . - " )," . PHP_EOL; - $this->assertStringContainsString($dependencies, $contents); + $installedDatabaseSettings = $this->getInstalledDatabaseSettings(); + $this->assertSame("Drupal\\driver_test\\Driver\\Database\\{$this->testDriverName}", $installedDatabaseSettings['default']['default']['namespace']); + $this->assertSame($this->testDriverName, $installedDatabaseSettings['default']['default']['driver']); + $this->assertSame("core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/", $installedDatabaseSettings['default']['default']['autoload']); + $this->assertEquals([ + 'mysql' => [ + 'namespace' => 'Drupal\\mysql', + 'autoload' => 'core/modules/mysql/src/', + ], + 'pgsql' => [ + 'namespace' => 'Drupal\\pgsql', + 'autoload' => 'core/modules/pgsql/src/', + ], + ], $installedDatabaseSettings['default']['default']['dependencies']); // Assert that the module "driver_test" and its dependencies have been // installed. @@ -99,4 +94,22 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase { $this->assertSession()->elementTextContains('xpath', '//tr[@data-drupal-selector="edit-pgsql"]', "The following reason prevents PostgreSQL from being uninstalled: Required by: driver_test"); } + /** + * Returns the databases setup from the SUT's settings.php. + * + * @return array<string,mixed> + * The value of the $databases variable. + */ + protected function getInstalledDatabaseSettings(): array { + // The $app_root and $site_path variables are required by the settings.php + // file to be parsed correctly. The $databases variable is set in the + // included file, we need to inform PHPStan about that since PHPStan itself + // is unable to determine it. + $app_root = $this->container->getParameter('app.root'); + $site_path = $this->siteDirectory; + include $app_root . '/' . $site_path . '/settings.php'; + assert(isset($databases)); + return $databases; + } + } diff --git a/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php b/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php new file mode 100644 index 00000000000..147b647d2da --- /dev/null +++ b/core/tests/Drupal/KernelTests/Components/ComponentInFormTest.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Components; + +use Drupal\Core\Form\FormInterface; +use Drupal\Core\Form\FormState; +use Drupal\Core\Form\FormStateInterface; + +/** + * Tests the correct rendering of components in form. + * + * @group sdc + */ +class ComponentInFormTest extends ComponentKernelTestBase implements FormInterface { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'sdc_test', + ]; + + /** + * {@inheritdoc} + */ + protected static $themes = ['sdc_theme_test']; + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'component_in_form_test'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form['normal'] = [ + '#type' => 'textfield', + '#title' => 'Normal form element', + '#default_value' => 'fake 1', + ]; + + // We want to test form elements inside a component, itself inside a + // component. + $form['banner'] = [ + '#type' => 'component', + '#component' => 'sdc_test:my-banner', + '#props' => [ + 'ctaText' => 'Click me!', + 'ctaHref' => 'https://www.example.org', + 'ctaTarget' => '', + ], + 'banner_body' => [ + '#type' => 'component', + '#component' => 'sdc_theme_test:my-card', + '#props' => [ + 'header' => 'Card header', + ], + 'card_body' => [ + 'foo' => [ + '#type' => 'textfield', + '#title' => 'Textfield in component', + '#default_value' => 'fake 2', + ], + 'bar' => [ + '#type' => 'select', + '#title' => 'Select in component', + '#options' => [ + 'option_1' => 'Option 1', + 'option_2' => 'Option 2', + ], + '#empty_option' => 'Empty option', + '#default_value' => 'option_1', + ], + ], + ], + ]; + + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => 'Submit', + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + // Check that submitted data are present (set with #default_value). + $data = [ + 'normal' => 'fake 1', + 'foo' => 'fake 2', + 'bar' => 'option_1', + ]; + foreach ($data as $key => $value) { + $this->assertSame($value, $form_state->getValue($key)); + } + } + + /** + * Tests that fields validation messages are sorted in the fields order. + */ + public function testFormRenderingAndSubmission(): void { + /** @var \Drupal\Core\Form\FormBuilderInterface $form_builder */ + $form_builder = \Drupal::service('form_builder'); + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + $form = $form_builder->getForm($this); + + // Test form structure after being processed. + $this->assertTrue($form['normal']['#processed'], 'The normal textfield should have been processed.'); + $this->assertTrue($form['banner']['banner_body']['card_body']['bar']['#processed'], 'The textfield inside component should have been processed.'); + $this->assertTrue($form['banner']['banner_body']['card_body']['foo']['#processed'], 'The select inside component should have been processed.'); + $this->assertTrue($form['actions']['submit']['#processed'], 'The submit button should have been processed.'); + + // Test form rendering. + $markup = $renderer->renderRoot($form); + $this->setRawContent($markup); + + // Ensure form elements are rendered once. + $this->assertCount(1, $this->cssSelect('input[name="normal"]'), 'The normal textfield should have been rendered once.'); + $this->assertCount(1, $this->cssSelect('input[name="foo"]'), 'The foo textfield should have been rendered once.'); + $this->assertCount(1, $this->cssSelect('select[name="bar"]'), 'The bar select should have been rendered once.'); + + // Check the position of the form elements in the DOM. + $paths = [ + '//form/div[1]/input[@name="normal"]', + '//form/div[2][@data-component-id="sdc_test:my-banner"]/div[2][@class="component--my-banner--body"]/div[1][@data-component-id="sdc_theme_test:my-card"]/div[1][@class="component--my-card__body"]/div[1]/input[@name="foo"]', + '//form/div[2][@data-component-id="sdc_test:my-banner"]/div[2][@class="component--my-banner--body"]/div[1][@data-component-id="sdc_theme_test:my-card"]/div[1][@class="component--my-card__body"]/div[2]/select[@name="bar"]', + ]; + foreach ($paths as $path) { + $this->assertNotEmpty($this->xpath($path), 'There should be a result with the path: ' . $path . '.'); + } + + // Test form submission. Assertions are in submitForm(). + $form_state = new FormState(); + $form_builder->submitForm($this, $form_state); + } + +} diff --git a/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php new file mode 100644 index 00000000000..1b012c9f726 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Components; + +/** + * Tests discovery of components in a theme being installed or uninstalled. + * + * @group sdc + */ +class ComponentPluginManagerCachedDiscoveryTest extends ComponentKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $themes = ['stark']; + + /** + * Tests cached component plugin discovery on theme install and uninstall. + */ + public function testComponentDiscoveryOnThemeInstall(): void { + // Component in sdc_theme should not be found without sdc_theme installed. + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions); + + // Component in sdc_theme should be found once sdc_theme is installed. + \Drupal::service('theme_installer')->install(['sdc_theme_test']); + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayHasKey('sdc_theme_test:bar', $definitions); + + // Component in sdc_theme should not be found once sdc_theme is uninstalled. + \Drupal::service('theme_installer')->uninstall(['sdc_theme_test']); + $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions(); + $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions); + } + +} diff --git a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php index 3ca62dcced5..f375f6f3dd6 100644 --- a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php +++ b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php @@ -377,6 +377,7 @@ class MappingTest extends KernelTestBase { 'field.value.decimal' => ['value'], 'field.value.float' => ['value'], 'field.value.timestamp' => ['value'], + 'field.value.language' => ['value'], 'field.value.comment' => [ 'status', 'cid', diff --git a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php index 340dcc28649..15c97bea71f 100644 --- a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php +++ b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php @@ -99,6 +99,20 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase { protected function setUp(): void { parent::setUp(); + // Install all core themes. + sort($this->allThemes); + $this->container->get('theme_installer')->install($this->allThemes); + + $this->themeHandler = $this->container->get('theme_handler'); + $this->themeInitialization = $this->container->get('theme.initialization'); + $this->themeManager = $this->container->get('theme.manager'); + $this->libraryDiscovery = $this->container->get('library.discovery'); + } + + /** + * Ensures that all core module and theme library files exist. + */ + public function testCoreLibraryCompleteness(): void { // Enable all core modules. $all_modules = $this->container->get('extension.list.module')->getList(); $all_modules = array_filter($all_modules, function ($module) { @@ -141,21 +155,37 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase { } sort($this->allModules); $this->container->get('module_installer')->install($this->allModules); + // Get a library discovery from the new container. + $this->libraryDiscovery = $this->container->get('library.discovery'); - // Install all core themes. - sort($this->allThemes); - $this->container->get('theme_installer')->install($this->allThemes); + $this->assertLibraries(); + } - $this->themeHandler = $this->container->get('theme_handler'); - $this->themeInitialization = $this->container->get('theme.initialization'); - $this->themeManager = $this->container->get('theme.manager'); + /** + * Ensures that module and theme library files exist for a deprecated modules. + * + * @group legacy + */ + public function testCoreLibraryCompletenessDeprecated(): void { + // Find and install deprecated modules to test. + $all_modules = $this->container->get('extension.list.module')->getList(); + $deprecated_modules_to_test = array_filter($all_modules, function ($module) { + if ($module->origin == 'core' + && $module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { + return TRUE; + } + }); + $this->container->get('module_installer')->install(array_keys($deprecated_modules_to_test)); $this->libraryDiscovery = $this->container->get('library.discovery'); + $this->allModules = array_keys(\Drupal::moduleHandler()->getModuleList()); + + $this->assertLibraries(); } /** - * Ensures that all core module and theme library files exist. + * Asserts the libraries for modules and themes exist. */ - public function testCoreLibraryCompleteness(): void { + public function assertLibraries(): void { // First verify all libraries with no active theme. $this->verifyLibraryFilesExist($this->getAllLibraries()); diff --git a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php index c2bff123236..c1ff7e24406 100644 --- a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php +++ b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Cache; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheTagsPurgeInterface; use Drupal\Core\Database\Database; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\KernelTests\KernelTestBase; @@ -62,4 +63,34 @@ class DatabaseBackendTagTest extends KernelTestBase { $this->assertEquals($invalidations_before + 1, $invalidations_after, 'Only one addition cache tag invalidation has occurred after invalidating a tag used in multiple bins.'); } + /** + * Test cache tag purging. + */ + public function testTagsPurge(): void { + $tags = ['test_tag:1', 'test_tag:2', 'test_tag:3']; + /** @var \Drupal\Core\Cache\CacheTagsChecksumInterface $checksum_invalidator */ + $checksum_invalidator = \Drupal::service('cache_tags.invalidator.checksum'); + // Assert that initial current tag checksum is 0. This also ensures that the + // 'cachetags' table is created, which at this point does not exist yet. + $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags)); + + /** @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator */ + $invalidator = \Drupal::service('cache_tags.invalidator'); + $invalidator->invalidateTags($tags); + // Checksum should be incremented by 1 by the invalidation for each tag. + $this->assertEquals(3, $checksum_invalidator->getCurrentChecksum($tags)); + + // After purging, confirm checksum is 0 and the 'cachetags' table is empty. + $this->assertInstanceOf(CacheTagsPurgeInterface::class, $invalidator); + $invalidator->purge(); + $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags)); + + $rows = Database::getConnection()->select('cachetags') + ->fields('cachetags') + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEmpty($rows, 'cachetags table is empty.'); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php index c361c7af959..cbda6e3d7f7 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php @@ -432,9 +432,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { /** * Tests rollback after a DDL statement when no transactional DDL supported. - * - * @todo In drupal:12.0.0, rollBack will throw a - * TransactionOutOfOrderException. Adjust the test accordingly. */ public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void { if ($this->connection->supportsTransactionalDDL()) { @@ -919,9 +916,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * transaction including DDL statements is not possible, since a commit * happened already. We cannot decide what should be the status of the * callback, an exception is thrown. - * - * @todo In drupal:12.0.0, rollBack will throw a - * TransactionOutOfOrderException. Adjust the test accordingly. */ public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void { if ($this->connection->supportsTransactionalDDL()) { diff --git a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php index 6e28787f764..3bfaa18889a 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php @@ -20,9 +20,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchObject(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_OBJ]); foreach ($result as $record) { @@ -39,9 +39,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchArray(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_ASSOC]); foreach ($result as $record) { @@ -59,9 +59,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchNum(): void { - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); - $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $records = []; $result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_NUM]); foreach ($result as $record) { @@ -79,7 +79,7 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchAllColumn(): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $query = $this->connection->select('test'); $query->addField('test', 'name'); $query->orderBy('name'); @@ -94,7 +94,7 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchAllAssoc(): void { - $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $expected_result = [ "Singer" => [ "id" => "2", diff --git a/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php new file mode 100644 index 00000000000..f40a977f6f4 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php @@ -0,0 +1,1278 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Database; + +use Drupal\Core\Database\Database; +use Drupal\Core\Database\Transaction; +use Drupal\Core\Database\Transaction\ClientConnectionTransactionState; +use Drupal\Core\Database\Transaction\StackItem; +use Drupal\Core\Database\Transaction\StackItemType; +use Drupal\Core\Database\Transaction\TransactionManagerBase; +use Drupal\Core\Database\TransactionNameNonUniqueException; +use Drupal\Core\Database\TransactionOutOfOrderException; + +// cspell:ignore Tinky Winky Dipsy + +/** + * Tests the transactions, using the explicit ::commitOrRelease method. + * + * We test nesting by having two transaction layers, an outer and inner. The + * outer layer encapsulates the inner layer. Our transaction nesting abstraction + * should allow the outer layer function to call any function it wants, + * especially the inner layer that starts its own transaction, and be + * confident that, when the function it calls returns, its own transaction + * is still "alive." + * + * Call structure: + * transactionOuterLayer() + * Start transaction "A" + * transactionInnerLayer() + * Start transaction "B" (does nothing in database) + * [Maybe decide to roll back "B"] + * Do more stuff + * Should still be in transaction "A" + * + * These method can be overridden by non-core database driver if their + * transaction behavior is different from core. For example, both oci8 (Oracle) + * and mysqli (MySql) clients do not have a solution to check if a transaction + * is active, and mysqli does not fail when rolling back and no transaction + * active. + * + * @group Database + */ +class TransactionTest extends DatabaseTestBase { + + /** + * Keeps track of the post-transaction callback action executed. + */ + protected ?string $postTransactionCallbackAction = NULL; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Set the transaction manager to trigger warnings when appropriate. + $this->connection->transactionManager()->triggerWarningWhenUnpilingOnVoidTransaction = TRUE; + } + + /** + * Create a root Drupal transaction. + */ + protected function createRootTransaction(string $name = '', bool $insertRow = TRUE): Transaction { + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + + // Start root transaction. Corresponds to 'BEGIN TRANSACTION' on the + // database. + $transaction = $this->connection->startTransaction($name); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a single row into the testing table. + if ($insertRow) { + $this->insertRow('David'); + $this->assertRowPresent('David'); + } + + return $transaction; + } + + /** + * Create a Drupal savepoint transaction after root. + */ + protected function createFirstSavepointTransaction(string $name = '', bool $insertRow = TRUE): Transaction { + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_1' + // on the database. The name can be changed by the $name argument. + $savepoint = $this->connection->startTransaction($name); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + + // Insert a single row into the testing table. + if ($insertRow) { + $this->insertRow('Roger'); + $this->assertRowPresent('Roger'); + } + + return $savepoint; + } + + /** + * Encapsulates a transaction's "inner layer" with an "outer layer". + * + * This "outer layer" transaction starts and then encapsulates the "inner + * layer" transaction. This nesting is used to evaluate whether the database + * transaction API properly supports nesting. By "properly supports," we mean + * the outer transaction continues to exist regardless of what functions are + * called and whether those functions start their own transactions. + * + * In contrast, a typical database would commit the outer transaction, start + * a new transaction for the inner layer, commit the inner layer transaction, + * and then be confused when the outer layer transaction tries to commit its + * transaction (which was already committed when the inner transaction + * started). + * + * @param string $suffix + * Suffix to add to field values to differentiate tests. + */ + protected function transactionOuterLayer(string $suffix): void { + $txn = $this->connection->startTransaction(); + + // Insert a single row into the testing table. + $this->connection->insert('test') + ->fields([ + 'name' => 'David' . $suffix, + 'age' => '24', + ]) + ->execute(); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction before calling nested transaction.'); + + // We're already in a transaction, but we call ->transactionInnerLayer + // to nest another transaction inside the current one. + $this->transactionInnerLayer($suffix); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction after calling nested transaction.'); + + $txn->commitOrRelease(); + } + + /** + * Creates an "inner layer" transaction. + * + * This "inner layer" transaction is either used alone or nested inside of the + * "outer layer" transaction. + * + * @param string $suffix + * Suffix to add to field values to differentiate tests. + */ + protected function transactionInnerLayer(string $suffix): void { + $depth = $this->connection->transactionManager()->stackDepth(); + // Start a transaction. If we're being called from ->transactionOuterLayer, + // then we're already in a transaction. Normally, that would make starting + // a transaction here dangerous, but the database API handles this problem + // for us by tracking the nesting and avoiding the danger. + $txn = $this->connection->startTransaction(); + + $depth2 = $this->connection->transactionManager()->stackDepth(); + $this->assertSame($depth + 1, $depth2, 'Transaction depth has increased with new transaction.'); + + // Insert a single row into the testing table. + $this->connection->insert('test') + ->fields([ + 'name' => 'Daniel' . $suffix, + 'age' => '19', + ]) + ->execute(); + + $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.'); + + $txn->commitOrRelease(); + } + + /** + * Tests root transaction rollback. + */ + public function testRollbackRoot(): void { + $transaction = $this->createRootTransaction(); + + // Rollback. Since we are at the root, the transaction is closed. + // Corresponds to 'ROLLBACK' on the database. + $transaction->rollBack(); + $this->assertRowAbsent('David'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests root transaction rollback after savepoint rollback. + */ + public function testRollbackRootAfterSavepointRollback(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Try to rollback root. No savepoint is active, this should succeed. + $transaction->rollBack(); + $this->assertRowAbsent('David'); + $this->assertRowAbsent('Roger'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests root transaction rollback failure when savepoint is open. + */ + public function testRollbackRootWithActiveSavepoint(): void { + $transaction = $this->createRootTransaction(); + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint = $this->createFirstSavepointTransaction(); + + // Try to rollback root. Since a savepoint is active, this should fail. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\drupal_transaction\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1/"); + $transaction->rollBack(); + } + + /** + * Tests savepoint transaction rollback. + */ + public function testRollbackSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction commit after rollback. + */ + public function testCommitAfterRollbackSameSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Try releasing savepoint. Should fail since it was released already. + try { + $savepoint->commitOrRelease(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting commit of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction rollback after commit. + */ + public function testRollbackAfterCommitSameSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Release savepoint. Corresponds to 'RELEASE savepoint_1' on the database. + $savepoint->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Try rolling back savepoint. Should fail since it was released already. + try { + $savepoint->rollback(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Commit root. + $transaction->commitOrRelease(); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertFalse($this->connection->inTransaction()); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction duplicated rollback. + */ + public function testRollbackTwiceSameSavepoint(): void { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK + // TO savepoint_1' plus 'RELEASE savepoint_1' on the database. + $savepoint->rollBack(); + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + + // Rollback savepoint again. Should fail since it was released already. + try { + $savepoint->rollBack(); + $this->fail('Expected TransactionOutOfOrderException was not thrown'); + } + catch (\Exception $e) { + $this->assertInstanceOf(TransactionOutOfOrderException::class, $e); + $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage()); + } + $this->assertRowPresent('David'); + $this->assertRowAbsent('Roger'); + $this->assertRowPresent('Syd'); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Tests savepoint transaction rollback failure when later savepoints exist. + */ + public function testRollbackSavepointWithLaterSavepoint(): void { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $transaction = $this->createRootTransaction(); + $savepoint1 = $this->createFirstSavepointTransaction(); + + // Starts another savepoint transaction. Corresponds to 'SAVEPOINT + // savepoint_2' on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint2 = $this->connection->startTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + + // Insert a row. + $this->insertRow('Syd'); + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertRowPresent('Syd'); + + // Try to rollback to savepoint 1. Out of order. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1 > .*\\\\savepoint_2/"); + $savepoint1->rollBack(); + } + + /** + * Tests commit does not fail when committing after DDL. + * + * In core, SQLite and PostgreSql databases support transactional DDL, MySql + * does not. + */ + public function testCommitAfterDdl(): void { + $transaction = $this->createRootTransaction(); + $savepoint = $this->createFirstSavepointTransaction(); + + $this->executeDDLStatement(); + + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + if ($this->connection->supportsTransactionalDDL()) { + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + } + else { + $this->assertFalse($this->connection->inTransaction()); + } + + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + if ($this->connection->supportsTransactionalDDL()) { + $savepoint->commitOrRelease(); + $this->assertTrue($this->connection->inTransaction()); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $savepoint->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + $this->assertFalse($this->connection->inTransaction()); + } + + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('David'); + $this->assertRowPresent('Roger'); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests a committed transaction. + * + * The behavior of this test should be identical for connections that support + * transactions and those that do not. + */ + public function testCommittedTransaction(): void { + // Create two nested transactions. The changes should be committed. + $this->transactionOuterLayer('A'); + + // Because we committed, both of the inserted rows should be present. + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DavidA'])->fetchField(); + $this->assertSame('24', $saved_age, 'Can retrieve DavidA row after commit.'); + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DanielA'])->fetchField(); + $this->assertSame('19', $saved_age, 'Can retrieve DanielA row after commit.'); + } + + /** + * Tests the compatibility of transactions with DDL statements. + */ + public function testTransactionWithDdlStatement(): void { + // First, test that a commit works normally, even with DDL statements. + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('row'); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('row'); + + // Even in different order. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->executeDDLStatement(); + $this->insertRow('row'); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $this->assertRowPresent('row'); + + // Even with stacking. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction2->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction2->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->commitOrRelease(); + + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, this is out or order since $transaction3 started a + // new root. + } + } + $this->assertRowPresent('row'); + + // A transaction after a DDL statement should still work the same. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction2->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction2->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->rollBack(); + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, this is out or order since $transaction3 started a + // new root. + } + } + $this->assertRowAbsent('row'); + + // The behavior of a rollback depends on the type of database server. + if ($this->connection->supportsTransactionalDDL()) { + // For database servers that support transactional DDL, a rollback + // of a transaction including DDL statements should be possible. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('row'); + $this->executeDDLStatement(); + $transaction->rollBack(); + $this->assertRowAbsent('row'); + + // Including with stacking. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->executeDDLStatement(); + $transaction2->commitOrRelease(); + $transaction3 = $this->connection->startTransaction(); + $this->insertRow('row'); + $transaction3->commitOrRelease(); + $this->assertRowPresent('row'); + $transaction->rollBack(); + $this->assertRowAbsent('row'); + } + } + + /** + * Tests rollback after a DDL statement when no transactional DDL supported. + */ + public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void { + if ($this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database that do not support transactional DDL.'); + } + + // For database servers that do not support transactional DDL, + // the DDL statement should commit the transaction stack. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $reflectionMethod = new \ReflectionMethod(get_class($this->connection->transactionManager()), 'getConnectionTransactionState'); + $this->assertSame(1, $this->connection->transactionManager()->stackDepth()); + $this->assertEquals(ClientConnectionTransactionState::Active, $reflectionMethod->invoke($this->connection->transactionManager())); + $this->insertRow('row'); + $this->executeDDLStatement(); + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + $this->assertEquals(ClientConnectionTransactionState::Voided, $reflectionMethod->invoke($this->connection->transactionManager())); + + // Try to rollback the root transaction. Since the DDL already committed + // it, it should fail. + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->rollBack(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + + try { + $transaction->commitOrRelease(); + $this->fail('TransactionOutOfOrderException was expected, but did not throw.'); + } + catch (TransactionOutOfOrderException) { + // Just continue, the attempted rollback made the overall state to + // ClientConnectionTransactionState::RollbackFailed. + } + + $manager = $this->connection->transactionManager(); + $this->assertSame(0, $manager->stackDepth()); + $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState'); + $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager)); + $this->assertRowPresent('row'); + } + + /** + * Inserts a single row into the testing table. + */ + protected function insertRow(string $name): void { + $this->connection->insert('test') + ->fields([ + 'name' => $name, + ]) + ->execute(); + } + + /** + * Executes a DDL statement. + */ + protected function executeDDLStatement(): void { + static $count = 0; + $table = [ + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + ], + 'primary key' => ['id'], + ]; + $this->connection->schema()->createTable('database_test_' . ++$count, $table); + } + + /** + * Starts over for a new test. + */ + protected function cleanUp(): void { + $this->connection->truncate('test') + ->execute(); + $this->postTransactionCallbackAction = NULL; + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + } + + /** + * Asserts that a given row is present in the test table. + * + * @param string $name + * The name of the row. + * @param string $message + * The message to log for the assertion. + * + * @internal + */ + public function assertRowPresent(string $name, ?string $message = NULL): void { + $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); + $this->assertTrue($present, $message ?? "Row '{$name}' should be present, but it actually does not exist."); + } + + /** + * Asserts that a given row is absent from the test table. + * + * @param string $name + * The name of the row. + * @param string $message + * The message to log for the assertion. + * + * @internal + */ + public function assertRowAbsent(string $name, ?string $message = NULL): void { + $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); + $this->assertFalse($present, $message ?? "Row '{$name}' should be absent, but it actually exists."); + } + + /** + * Tests transaction stacking, commit, and rollback. + */ + public function testTransactionStacking(): void { + // Standard case: pop the inner transaction before the outer transaction. + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('outer'); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->insertRow('inner'); + // Pop the inner transaction. + $transaction2->commitOrRelease(); + $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the inner transaction'); + // Pop the outer transaction. + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the outer transaction'); + $this->assertRowPresent('outer'); + $this->assertRowPresent('inner'); + + // Rollback the inner transaction. + $this->cleanUp(); + $transaction = $this->createRootTransaction('', FALSE); + $this->insertRow('outer'); + $transaction2 = $this->createFirstSavepointTransaction('', FALSE); + $this->insertRow('inner'); + // Now rollback the inner transaction. + $transaction2->rollBack(); + $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction'); + // Pop the outer transaction, it should commit. + $this->insertRow('outer-after-inner-rollback'); + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction'); + $this->assertRowPresent('outer'); + $this->assertRowAbsent('inner'); + $this->assertRowPresent('outer-after-inner-rollback'); + } + + /** + * Tests that transactions can continue to be used if a query fails. + */ + public function testQueryFailureInTransaction(): void { + $transaction = $this->createRootTransaction('test_transaction', FALSE); + $this->connection->schema()->dropTable('test'); + + // Test a failed query using the query() method. + try { + $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField(); + $this->fail('Using the query method should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed select query. + try { + $this->connection->select('test') + ->fields('test', ['name']) + ->execute(); + + $this->fail('Select query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed insert query. + try { + $this->connection->insert('test') + ->fields([ + 'name' => 'David', + 'age' => '24', + ]) + ->execute(); + + $this->fail('Insert query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed update query. + try { + $this->connection->update('test') + ->fields(['name' => 'Tiffany']) + ->condition('id', 1) + ->execute(); + + $this->fail('Update query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed delete query. + try { + $this->connection->delete('test') + ->condition('id', 1) + ->execute(); + + $this->fail('Delete query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed merge query. + try { + $this->connection->merge('test') + ->key('job', 'Presenter') + ->fields([ + 'age' => '31', + 'name' => 'Tiffany', + ]) + ->execute(); + + $this->fail('Merge query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Test a failed upsert query. + try { + $this->connection->upsert('test') + ->key('job') + ->fields(['job', 'age', 'name']) + ->values([ + 'job' => 'Presenter', + 'age' => 31, + 'name' => 'Tiffany', + ]) + ->execute(); + + $this->fail('Upsert query should have failed.'); + } + catch (\Exception) { + // Just continue testing. + } + + // Create the missing schema and insert a row. + $this->installSchema('database_test', ['test']); + $this->connection->insert('test') + ->fields([ + 'name' => 'David', + 'age' => '24', + ]) + ->execute(); + + // Commit the transaction. + if ($this->connection->supportsTransactionalDDL()) { + $transaction->commitOrRelease(); + } + else { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->commitOrRelease(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + } + + $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField(); + $this->assertEquals('24', $saved_age); + } + + /** + * Tests releasing a savepoint before last is safe. + */ + public function testReleaseIntermediateSavepoint(): void { + $transaction = $this->createRootTransaction(); + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2' + // on the database. + $savepoint2 = $this->connection->startTransaction(); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_3' + // on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint3 = $this->connection->startTransaction(); + $this->assertSame(4, $this->connection->transactionManager()->stackDepth()); + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_4' + // on the database. + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint4 = $this->connection->startTransaction(); + $this->assertSame(5, $this->connection->transactionManager()->stackDepth()); + + $this->insertRow('row'); + + // Release savepoint transaction. Corresponds to 'RELEASE SAVEPOINT + // savepoint_2' on the database. + $savepoint2->commitOrRelease(); + // Since we have committed an intermediate savepoint Transaction object, + // the savepoints created later have been dropped by the database already. + $this->assertSame(2, $this->connection->transactionManager()->stackDepth()); + $this->assertRowPresent('row'); + + // Commit the remaining Transaction objects. The client transaction is + // eventually committed. + $savepoint1->commitOrRelease(); + $transaction->commitOrRelease(); + $this->assertFalse($this->connection->inTransaction()); + $this->assertRowPresent('row'); + } + + /** + * Tests committing a transaction while savepoints are active. + */ + public function testCommitWithActiveSavepoint(): void { + $transaction = $this->createRootTransaction(); + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + + // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2' + // on the database. + $savepoint2 = $this->connection->startTransaction(); + $this->assertSame(3, $this->connection->transactionManager()->stackDepth()); + + $this->insertRow('row'); + + // Commit the root transaction. + $transaction->commitOrRelease(); + // Since we have committed the outer (root) Transaction object, the inner + // (savepoint) ones have been dropped by the database already, and we are + // no longer in an active transaction state. + $this->assertSame(0, $this->connection->transactionManager()->stackDepth()); + $this->assertFalse($this->connection->inTransaction()); + $this->assertRowPresent('row'); + // Trying to release the inner (savepoint) Transaction object, throws an + // exception since it was dropped by the database already, and removed from + // our transaction stack. + $this->expectException(TransactionOutOfOrderException::class); + $this->expectExceptionMessageMatches("/^Error attempting commit of .*\\\\savepoint_2\\. Active stack: .* empty/"); + $savepoint2->commitOrRelease(); + } + + /** + * Tests for transaction names. + */ + public function testTransactionName(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->assertSame('drupal_transaction', $transaction->name()); + + $savepoint1 = $this->createFirstSavepointTransaction('', FALSE); + $this->assertSame('savepoint_1', $savepoint1->name()); + + $this->expectException(TransactionNameNonUniqueException::class); + $this->expectExceptionMessage("savepoint_1 is already in use."); + $this->connection->startTransaction('savepoint_1'); + } + + /** + * Tests for arbitrary transaction names. + */ + public function testArbitraryTransactionNames(): void { + $transaction = $this->createRootTransaction('TinkyWinky', FALSE); + // Despite setting a name, the root transaction is always named + // 'drupal_transaction'. + $this->assertSame('drupal_transaction', $transaction->name()); + + $savepoint1 = $this->createFirstSavepointTransaction('Dipsy', FALSE); + $this->assertSame('Dipsy', $savepoint1->name()); + + $this->expectException(TransactionNameNonUniqueException::class); + $this->expectExceptionMessage("Dipsy is already in use."); + $this->connection->startTransaction('Dipsy'); + } + + /** + * Tests that adding a post-transaction callback fails with no transaction. + */ + public function testRootTransactionEndCallbackAddedWithoutTransaction(): void { + $this->expectException(\LogicException::class); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + } + + /** + * Tests post-transaction callback executes after transaction commit. + */ + public function testRootTransactionEndCallbackCalledOnCommit(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + + // Callbacks are processed only when destructing the transaction. + // Executing a commit is not sufficient by itself. + $transaction->commitOrRelease(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowPresent('row'); + $this->assertRowAbsent('rtcCommit'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcCommit' + // row. + $this->assertSame('rtcCommit', $this->postTransactionCallbackAction); + $this->assertRowPresent('row'); + $this->assertRowPresent('rtcCommit'); + } + + /** + * Tests post-transaction callback executes after transaction rollback. + */ + public function testRootTransactionEndCallbackCalledAfterRollbackAndDestruction(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a rollback is not sufficient by itself. + $transaction->rollBack(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowAbsent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $this->assertRowAbsent('row'); + } + + /** + * Tests post-transaction callback executes after a DDL statement. + */ + public function testRootTransactionEndCallbackCalledAfterDdlAndDestruction(): void { + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcCommit' + // row. + $this->assertSame('rtcCommit', $this->postTransactionCallbackAction); + $this->assertRowPresent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + } + + /** + * Tests post-transaction rollback executes after a DDL statement. + * + * For database servers that support transactional DDL, a rollback of a + * transaction including DDL statements is possible. + */ + public function testRootTransactionEndCallbackCalledAfterDdlAndRollbackForTransactionalDdlDatabase(): void { + if (!$this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database supporting transactional DDL.'); + } + + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + // Callbacks are processed only when destructing the transaction. + // Executing the rollback is not sufficient by itself. + $transaction->rollBack(); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowAbsent('row'); + + // Destruct the transaction. + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $this->assertRowAbsent('row'); + } + + /** + * Tests post-transaction rollback failure after a DDL statement. + * + * For database servers that support transactional DDL, a rollback of a + * transaction including DDL statements is not possible, since a commit + * happened already. We cannot decide what should be the status of the + * callback, an exception is thrown. + */ + public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void { + if ($this->connection->supportsTransactionalDDL()) { + $this->markTestSkipped('This test only works for database that do not support transactional DDL.'); + } + + $transaction = $this->createRootTransaction('', FALSE); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + + // Callbacks are processed only when destructing the transaction. + // Executing a DDL statement is not sufficient itself. + // We cannot use truncate here, since it has protective code to fall back + // to a transactional delete when in transaction. We drop an unrelated + // table instead. + $this->connection->schema()->dropTable('test_people'); + $this->assertNull($this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowAbsent('rtcRollback'); + $this->assertRowPresent('row'); + + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \ErrorException($errstr); + }); + try { + $transaction->rollBack(); + } + catch (\ErrorException $e) { + $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage()); + } + finally { + restore_error_handler(); + } + + unset($transaction); + + // The post-transaction callback should now have inserted a 'rtcRollback' + // row. + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + $this->assertRowAbsent('rtcCommit'); + $this->assertRowPresent('rtcRollback'); + $manager = $this->connection->transactionManager(); + $this->assertSame(0, $manager->stackDepth()); + $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState'); + $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager)); + $this->assertRowPresent('row'); + } + + /** + * A post-transaction callback for testing purposes. + */ + public function rootTransactionCallback(bool $success): void { + $this->postTransactionCallbackAction = $success ? 'rtcCommit' : 'rtcRollback'; + $this->insertRow($this->postTransactionCallbackAction); + } + + /** + * Tests TransactionManager failure. + */ + public function testTransactionManagerFailureOnPendingStackItems(): void { + $connectionInfo = Database::getConnectionInfo(); + Database::addConnectionInfo('default', 'test_fail', $connectionInfo['default']); + $testConnection = Database::getConnection('test_fail'); + + // Add a fake item to the stack. + $manager = $testConnection->transactionManager(); + $reflectionMethod = new \ReflectionMethod($manager, 'addStackItem'); + $reflectionMethod->invoke($manager, 'bar', new StackItem('qux', StackItemType::Root)); + // Ensure transaction state can be determined during object destruction. + // This is necessary for the test to pass when xdebug.mode has the 'develop' + // option enabled. + $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'connectionTransactionState'); + $reflectionProperty->setValue($manager, ClientConnectionTransactionState::Active); + + // Ensure that __destruct() results in an assertion error. Note that this + // will normally be called by PHP during the object's destruction but Drupal + // will commit all transactions when a database is closed thereby making + // this impossible to test unless it is called directly. + try { + $manager->__destruct(); + $this->fail("Expected AssertionError error not thrown"); + } + catch (\AssertionError $e) { + $this->assertStringStartsWith('Transaction $stack was not empty. Active stack: bar\\qux', $e->getMessage()); + } + + // Clean up. + $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'stack'); + $reflectionProperty->setValue($manager, []); + unset($testConnection); + Database::closeConnection('test_fail'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php new file mode 100644 index 00000000000..5e708fbbc2f --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php @@ -0,0 +1,1036 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Entity; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\IntegrityConstraintViolationException; +use Drupal\Core\Entity\ContentEntityType; +use Drupal\Core\Entity\EntityStorageException; +use Drupal\Core\Entity\EntityTypeEvents; +use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\FieldException; +use Drupal\Core\Field\FieldStorageDefinitionEvents; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\entity_test\EntityTestHelper; +use Drupal\entity_test\FieldStorageDefinition; +use Drupal\entity_test_update\Entity\EntityTestUpdate; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; + +/** + * Tests EntityDefinitionUpdateManager functionality. + * + * @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager + * + * @group Entity + * @group #slow + */ +class EntityDefinitionUpdateMultipleTypesTest extends EntityKernelTestBase { + + use EntityDefinitionTestTrait; + + /** + * The entity definition update manager. + * + * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface + */ + protected $entityDefinitionUpdateManager; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * {@inheritdoc} + */ + protected static $modules = ['entity_test_update', 'language']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager'); + $this->entityFieldManager = $this->container->get('entity_field.manager'); + $this->database = $this->container->get('database'); + + // Install every entity type's schema that wasn't installed in the parent + // method. + foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) { + $this->installEntitySchema($entity_type_id); + } + } + + /** + * Tests when no definition update is needed. + */ + public function testNoUpdates(): void { + // Ensure that the definition update manager reports no updates. + $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.'); + $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.'); + $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.'); + } + + /** + * Tests updating entity schema when there are no existing entities. + */ + public function testEntityTypeUpdateWithoutData(): void { + // The 'entity_test_update' entity type starts out non-revisionable, so + // ensure the revision table hasn't been created during setUp(). + $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.'); + + // Update it to be revisionable and ensure the definition update manager + // reports that an update is needed. + $this->updateEntityTypeToRevisionable(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + // The revision key is now defined, so the revision field needs to be + // created. + 'The Revision ID field needs to be installed.', + 'The Default revision field needs to be installed.', + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the revision table is created. + $this->updateEntityTypeToRevisionable(TRUE); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.'); + } + + /** + * Tests updating entity schema when there are entity storage changes. + */ + public function testEntityTypeUpdateWithEntityStorageChange(): void { + // Update the entity type to be revisionable and try to apply the update. + // It's expected to throw an exception. + $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE); + try { + $this->entityDefinitionUpdateManager->updateEntityType($entity_type); + $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.'); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests creating a fieldable entity type that doesn't exist in code anymore. + * + * @covers ::installFieldableEntityType + */ + public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Install the entity type and check that its tables have been created. + $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.'); + } + + /** + * Tests updating an entity type that doesn't exist in code anymore. + * + * @covers ::updateEntityType + */ + public function testUpdateEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Add an entity index, update the entity type and check that the index has + // been created. + $this->addEntityIndex(); + $this->entityDefinitionUpdateManager->updateEntityType($entity_type); + + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + } + + /** + * Tests updating a fieldable entity type that doesn't exist in code anymore. + * + * @covers ::updateFieldableEntityType + */ + public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Rename the base table, update the fieldable entity type and check that + // the table has been renamed. + $entity_type->set('base_table', 'entity_test_update_new'); + $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions); + + $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.'); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.'); + } + + /** + * Tests uninstalling an entity type that doesn't exist in code anymore. + * + * @covers ::uninstallEntityType + */ + public function testUninstallEntityTypeWithoutInCodeDefinition(): void { + $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Now uninstall it and check that the tables have been removed. + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); + $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); + } + + /** + * Tests uninstalling a revisionable entity type that doesn't exist in code. + * + * @covers ::uninstallEntityType + */ + public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void { + $this->updateEntityTypeToRevisionable(TRUE); + $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); + + // Remove the entity type definition. This is the same thing as removing the + // code that defines it. + $this->deleteEntityType(); + + // Now uninstall it and check that the tables have been removed. + $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); + $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); + } + + /** + * Tests creating, updating, and deleting a base field if no entities exist. + */ + public function testBaseFieldCreateUpdateDeleteWithoutData(): void { + // Add a base field, ensure the update manager reports it, and the update + // creates its schema. + $this->addBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); + + // Add an index on the base field, ensure the update manager reports it, + // and the update creates it. + $this->addBaseFieldIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.'); + + // Remove the above index, ensure the update manager reports it, and the + // update deletes it. + $this->removeBaseFieldIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.'); + + // Update the type of the base field from 'string' to 'text', ensure the + // update manager reports it, and the update adjusts the schema + // accordingly. + $this->modifyBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.'); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.'); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.'); + + // Remove the base field, ensure the update manager reports it, and the + // update deletes the schema. + $this->removeBaseField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.'); + $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.'); + } + + /** + * Tests creating, updating, and deleting a base field with no label set. + * + * See testBaseFieldCreateUpdateDeleteWithoutData() for more details + */ + public function testBaseFieldWithoutLabelCreateUpdateDelete(): void { + // Add a base field, ensure the update manager reports it with the + // field id. + $this->addBaseField('string', 'entity_test_update', FALSE, FALSE); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + + // Add an index on the base field, ensure the update manager reports it with + // the field id. + $this->addBaseFieldIndex(); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + + // Remove the base field, ensure the update manager reports it with the + // field id. + $this->removeBaseField(); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + } + + /** + * Tests creating, updating, and deleting a bundle field if no entities exist. + */ + public function testBundleFieldCreateUpdateDeleteWithoutData(): void { + // Add a bundle field, ensure the update manager reports it, and the update + // creates its schema. + $this->addBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); + + // Update the type of the base field from 'string' to 'text', ensure the + // update manager reports it, and the update adjusts the schema + // accordingly. + $this->modifyBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.'); + + // Remove the bundle field, ensure the update manager reports it, and the + // update deletes the schema. + $this->removeBundleField(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); + $this->assertCount(1, $changes['entity_test_update']); + $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); + $this->applyEntityUpdates(); + $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); + } + + /** + * Tests creating and deleting a base field if entities exist. + * + * This tests deletion when there are existing entities, but non-existent data + * for the field being deleted. + * + * @see testBaseFieldDeleteWithExistingData() + */ + public function testBaseFieldCreateDeleteWithExistingEntities(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Add a base field and run the update. Ensure the base field's column is + // created and the prior saved entity data is still there. + $this->addBaseField(); + $this->applyEntityUpdates(); + $schema_handler = $this->database->schema(); + $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); + + // Remove the base field and run the update. Ensure the base field's column + // is deleted and the prior saved entity data is still there. + $this->removeBaseField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); + + // Add a base field with a required property and run the update. Ensure + // 'not null' is not applied and thus no exception is thrown. + $this->addBaseField('shape_required'); + $this->applyEntityUpdates(); + $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); + $this->assertTrue($assert, 'Columns created in shared table for new_base_field.'); + + // Recreate the field after emptying the base table and check that its + // columns are not 'not null'. + // @todo Revisit this test when allowing for required storage field + // definitions. See https://www.drupal.org/node/2390495. + $entity->delete(); + $this->removeBaseField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.'); + $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.'); + $this->addBaseField('shape_required'); + $this->applyEntityUpdates(); + $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); + $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + } + + /** + * Tests creating and deleting a bundle field if entities exist. + * + * This tests deletion when there are existing entities, but non-existent data + * for the field being deleted. + * + * @see testBundleFieldDeleteWithExistingData() + */ + public function testBundleFieldCreateDeleteWithExistingEntities(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Add a bundle field and run the update. Ensure the bundle field's table + // is created and the prior saved entity data is still there. + $this->addBundleField(); + $this->applyEntityUpdates(); + $schema_handler = $this->database->schema(); + $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); + + // Remove the base field and run the update. Ensure the bundle field's + // table is deleted and the prior saved entity data is still there. + $this->removeBundleField(); + $this->applyEntityUpdates(); + $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); + $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); + + // Test that required columns are created as 'not null'. + $this->addBundleField('shape_required'); + $this->applyEntityUpdates(); + $message = 'The new_bundle_field_shape column is not nullable.'; + $values = [ + 'bundle' => $entity->bundle(), + 'deleted' => 0, + 'entity_id' => $entity->id(), + 'revision_id' => $entity->id(), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'delta' => 0, + 'new_bundle_field_color' => $this->randomString(), + ]; + try { + // Try to insert a record without providing a value for the 'not null' + // column. This should fail. + $this->database->insert('entity_test_update__new_bundle_field') + ->fields($values) + ->execute(); + $this->fail($message); + } + catch (IntegrityConstraintViolationException) { + // Now provide a value for the 'not null' column. This is expected to + // succeed. + $values['new_bundle_field_shape'] = $this->randomString(); + $this->database->insert('entity_test_update__new_bundle_field') + ->fields($values) + ->execute(); + } + } + + /** + * Tests deleting a bundle field when it has existing data. + */ + public function testBundleFieldDeleteWithExistingData(): void { + /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $schema_handler = $this->database->schema(); + + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $storage->getTableMapping(); + $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field']; + + // Check that the bundle field has a dedicated table. + $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition); + $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.'); + + // Save an entity with the bundle field populated. + EntityTestHelper::createBundle('custom'); + $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']); + $entity->save(); + + // Remove the bundle field and apply updates. + $this->removeBundleField(); + $this->applyEntityUpdates(); + + // Check that the table of the bundle field has been renamed to use a + // 'deleted' table name. + $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.'); + + $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE); + $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.'); + + // Check that the deleted field's data is preserved in the dedicated + // 'deleted' table. + $result = $this->database->select($dedicated_deleted_table_name, 't') + ->fields('t') + ->execute() + ->fetchAll(); + $this->assertCount(1, $result); + + $expected = [ + 'bundle' => $entity->bundle(), + 'deleted' => '1', + 'entity_id' => $entity->id(), + 'revision_id' => $entity->id(), + 'langcode' => $entity->language()->getId(), + 'delta' => '0', + 'new_bundle_field_value' => $entity->new_bundle_field->value, + ]; + // Use assertEquals and not assertSame here to prevent that a different + // sequence of the columns in the table will affect the check. + $this->assertEquals($expected, (array) $result[0]); + + // Check that the field definition is marked for purging. + $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); + $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.'); + + // Check that the field storage definition is marked for purging. + $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); + $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.'); + + // Purge field data, and check that the storage definition has been + // completely removed once the data is purged. + field_purge_batch(10); + $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); + $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.'); + $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); + $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.'); + $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.'); + } + + /** + * Tests updating a base field when it has existing data. + */ + public function testBaseFieldUpdateWithExistingData(): void { + // Add the base field and run the update. + $this->addBaseField(); + $this->applyEntityUpdates(); + + // Save an entity with the base field populated. + $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save(); + + // Change the field's field type and apply updates. It's expected to + // throw an exception. + $this->modifyBaseField(); + try { + $this->applyEntityUpdates(); + $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); + } + catch (FieldStorageDefinitionUpdateForbiddenException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests updating a bundle field when it has existing data. + */ + public function testBundleFieldUpdateWithExistingData(): void { + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + // Save an entity with the bundle field populated. + EntityTestHelper::createBundle('custom'); + $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save(); + + // Change the field's field type and apply updates. It's expected to + // throw an exception. + $this->modifyBundleField(); + try { + $this->applyEntityUpdates(); + $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); + } + catch (FieldStorageDefinitionUpdateForbiddenException) { + // Expected exception; just continue testing. + } + } + + /** + * Tests updating a bundle field when the entity type schema has changed. + */ + public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void { + // Add the bundle field and run the update. + $this->addBundleField(); + $this->applyEntityUpdates(); + + // Update the entity type schema to revisionable but don't run the updates + // yet. + $this->updateEntityTypeToRevisionable(); + + // Perform a no-op update on the bundle field, which should work because + // both the storage and the storage schema are using the last installed + // entity type definition. + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update')); + } + + /** + * Tests creating and deleting a multi-field index when there are no existing entities. + */ + public function testEntityIndexCreateDeleteWithoutData(): void { + // Add an entity index and ensure the update manager reports that as an + // update to the entity type. + $this->addEntityIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the new index is created. + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + + // Remove the index and ensure the update manager reports that as an + // update to the entity type. + $this->removeEntityIndex(); + $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); + $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); + $expected = [ + 'entity_test_update' => [ + "The $entity_type entity type needs to be updated.", + ], + ]; + $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); + + // Run the update and ensure the index is deleted. + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); + + // Test that composite indexes are handled correctly when dropping and + // re-creating one of their columns. + $this->addEntityIndex(); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + + $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update'); + $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); + $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); + $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.'); + } + + /** + * Tests creating a multi-field index when there are existing entities. + */ + public function testEntityIndexCreateWithData(): void { + // Save an entity. + $name = $this->randomString(); + $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]); + $entity->save(); + + // Add an entity index, run the update. Ensure that the index is created + // despite having data. + $this->addEntityIndex(); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.'); + } + + /** + * Tests applying single updates. + */ + public function testSingleActionCalls(): void { + $db_schema = $this->database->schema(); + + // Ensure that a non-existing entity type cannot be installed. + $message = 'A non-existing entity type cannot be installed'; + try { + $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo'])); + $this->fail($message); + } + catch (PluginNotFoundException) { + // Expected exception; just continue testing. + } + + // Ensure that a field cannot be installed on non-existing entity type. + $message = 'A field cannot be installed on a non-existing entity type'; + try { + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition); + $this->fail($message); + } + catch (PluginNotFoundException) { + // Expected exception; just continue testing. + } + + // Ensure that installing an existing entity type is a no-op. + $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); + $this->entityDefinitionUpdateManager->installEntityType($entity_type); + $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op'); + + // Create a new base field. + $this->addRevisionableBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + + // Ensure that installing an existing field is a no-op. + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op'); + + // Update an existing field schema. + $this->modifyBaseField(); + $storage_definition = BaseFieldDefinition::create('text') + ->setName('new_base_field') + ->setTargetEntityTypeId('entity_test_update') + ->setLabel('A new revisionable base field') + ->setRevisionable(TRUE); + $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists."); + $this->assertTrue( + $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), + "New schema for 'new_base_field' has been created." + ); + + // Drop an existing field schema. + $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); + $this->assertFalse( + $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), + "The schema for 'new_base_field' has been dropped." + ); + + // Make the entity type revisionable. + $this->updateEntityTypeToRevisionable(); + $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update."); + + $this->updateEntityTypeToRevisionable(TRUE); + $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created."); + } + + /** + * Ensures that a new field and index on a shared table are created. + * + * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema + */ + public function testCreateFieldAndIndexOnSharedTable(): void { + $this->addBaseField(); + $this->addBaseFieldIndex(); + $this->applyEntityUpdates(); + $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); + // Check index size in for MySQL. + if (Database::getConnection()->driver() == 'mysql') { + $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); + $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); + } + } + + /** + * Ensures that a new entity level index is created when data exists. + * + * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate + */ + public function testCreateIndexUsingEntityStorageSchemaWithData(): void { + // Save an entity. + $name = $this->randomString(); + $storage = $this->entityTypeManager->getStorage('entity_test_update'); + $entity = $storage->create(['name' => $name]); + $entity->save(); + + // Create an index. + $indexes = [ + 'entity_test_update__type_index' => ['type'], + ]; + $this->state->set('entity_test_update.additional_entity_indexes', $indexes); + $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); + $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); + + $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); + // Check index size in for MySQL. + if (Database::getConnection()->driver() == 'mysql') { + $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); + $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); + } + } + + /** + * Tests updating a base field when it has existing data. + */ + public function testBaseFieldEntityKeyUpdateWithExistingData(): void { + // Add the base field and run the update. + $this->addBaseField(); + $this->applyEntityUpdates(); + + // Save an entity with the base field populated. + $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save(); + + // Save an entity with the base field not populated. + /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */ + $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(); + $entity->save(); + + // Promote the base field to an entity key. This will trigger the addition + // of a NOT NULL constraint. + $this->makeBaseFieldEntityKey(); + + // Field storage CRUD operations use the last installed entity type + // definition so we need to update it before doing any other field storage + // updates. + $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type')); + + // Try to apply the update and verify they fail since we have a NULL value. + $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.'; + try { + $this->applyEntityUpdates(); + $this->fail($message); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + + // Check that the update is correctly applied when no NULL data is left. + $entity->set('new_base_field', $this->randomString()); + $entity->save(); + $this->applyEntityUpdates(); + + // Check that the update actually applied a NOT NULL constraint. + $entity->set('new_base_field', NULL); + $message = 'The NOT NULL constraint was correctly applied.'; + try { + $entity->save(); + $this->fail($message); + } + catch (EntityStorageException) { + // Expected exception; just continue testing. + } + } + + /** + * Check that field schema is correctly handled with long-named fields. + */ + public function testLongNameFieldIndexes(): void { + $this->addLongNameBaseField(); + $entity_type_id = 'entity_test_update'; + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type); + $name = 'new_long_named_entity_reference_base_field'; + $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]); + $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.'); + } + + /** + * Tests adding a base field with initial values. + */ + public function testInitialValue(): void { + $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); + $db_schema = $this->database->schema(); + + // Create two entities before adding the base field. + $storage->create()->save(); + $storage->create()->save(); + + // Add a base field with an initial value. + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new base field') + ->setInitialValue('test value'); + + $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); + + // Check that the initial values have been applied. + $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); + $entities = $storage->loadMultiple(); + $this->assertEquals('test value', $entities[1]->get('new_base_field')->value); + $this->assertEquals('test value', $entities[2]->get('new_base_field')->value); + } + + /** + * Tests entity type and field storage definition events. + */ + public function testDefinitionEvents(): void { + /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */ + $event_subscriber = $this->container->get('entity_test.definition.subscriber'); + $event_subscriber->enableEventTracking(); + $event_subscriber->enableLiveDefinitionUpdates(); + + // Test field storage definition events. + $storage_definition = FieldStorageDefinition::create('string') + ->setName('field_storage_test') + ->setLabel(new TranslatableMarkup('Field storage test')) + ->setTargetEntityTypeId('entity_test_rev'); + + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.'); + + // Check that the newly added field can be retrieved from the live field + // storage definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertArrayHasKey('field_storage_test', $field_storage_definitions); + + $updated_storage_definition = clone $storage_definition; + $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test')); + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.'); + + // Check that the updated field can be retrieved from the live field storage + // definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel()); + + $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.'); + \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition); + $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.'); + + // Check that the deleted field can no longer be retrieved from the live + // field storage definitions. + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); + $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions); + + // Test entity type events. + $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); + + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.'); + + $updated_entity_type = clone $entity_type; + $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev')); + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.'); + + // Check that the updated definition can be retrieved from the live entity + // type definitions. + $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); + $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel()); + + $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.'); + \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type); + $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.'); + $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.'); + + // Check that the deleted entity type can no longer be retrieved from the + // live entity type definitions. + $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE)); + } + + /** + * Tests the error handling when using initial values from another field. + */ + public function testInitialValueFromFieldErrorHandling(): void { + // Check that setting invalid values for 'initial value from field' doesn't + // work. + try { + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('string') + ->setLabel('A new base field') + ->setInitialValueFromField('field_that_does_not_exist'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->fail('Using a non-existent field as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage()); + } + + try { + $this->addBaseField(); + $storage_definition = BaseFieldDefinition::create('integer') + ->setLabel('A new base field') + ->setInitialValueFromField('name'); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); + $this->fail('Using a field of a different type as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage()); + } + + try { + // Add a base field that will not be stored in the shared tables. + $initial_field = BaseFieldDefinition::create('string') + ->setName('initial_field') + ->setLabel('An initial field') + ->setCardinality(2); + $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field); + + // Now add the base field which will try to use the previously added field + // as the source of its initial values. + $new_base_field = BaseFieldDefinition::create('string') + ->setName('new_base_field') + ->setLabel('A new base field') + ->setInitialValueFromField('initial_field'); + $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]); + $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field); + $this->fail('Using a field that is not stored in the shared tables as initial value does not work.'); + } + catch (FieldException $e) { + $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage()); + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php index 6437e594ee8..d184892a976 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php @@ -4,21 +4,6 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Entity; -use Drupal\Component\Plugin\Exception\PluginNotFoundException; -use Drupal\Core\Database\Database; -use Drupal\Core\Database\IntegrityConstraintViolationException; -use Drupal\Core\Entity\ContentEntityType; -use Drupal\Core\Entity\EntityStorageException; -use Drupal\Core\Entity\EntityTypeEvents; -use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException; -use Drupal\Core\Field\BaseFieldDefinition; -use Drupal\Core\Field\FieldException; -use Drupal\Core\Field\FieldStorageDefinitionEvents; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\entity_test\EntityTestHelper; -use Drupal\entity_test\FieldStorageDefinition; -use Drupal\entity_test_update\Entity\EntityTestUpdate; use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** @@ -27,7 +12,6 @@ use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; * @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager * * @group Entity - * @group #slow */ class EntityDefinitionUpdateTest extends EntityKernelTestBase { @@ -67,12 +51,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { $this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager'); $this->entityFieldManager = $this->container->get('entity_field.manager'); $this->database = $this->container->get('database'); - - // Install every entity type's schema that wasn't installed in the parent - // method. - foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) { - $this->installEntitySchema($entity_type_id); - } } /** @@ -96,61 +74,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { } /** - * Tests when no definition update is needed. - */ - public function testNoUpdates(): void { - // Ensure that the definition update manager reports no updates. - $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.'); - $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.'); - $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.'); - } - - /** - * Tests updating entity schema when there are no existing entities. - */ - public function testEntityTypeUpdateWithoutData(): void { - // The 'entity_test_update' entity type starts out non-revisionable, so - // ensure the revision table hasn't been created during setUp(). - $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.'); - - // Update it to be revisionable and ensure the definition update manager - // reports that an update is needed. - $this->updateEntityTypeToRevisionable(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - // The revision key is now defined, so the revision field needs to be - // created. - 'The Revision ID field needs to be installed.', - 'The Default revision field needs to be installed.', - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the revision table is created. - $this->updateEntityTypeToRevisionable(TRUE); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.'); - } - - /** - * Tests updating entity schema when there are entity storage changes. - */ - public function testEntityTypeUpdateWithEntityStorageChange(): void { - // Update the entity type to be revisionable and try to apply the update. - // It's expected to throw an exception. - $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE); - try { - $this->entityDefinitionUpdateManager->updateEntityType($entity_type); - $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.'); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - } - - /** * Tests installing an additional base field while installing an entity type. * * @covers ::installFieldableEntityType @@ -173,909 +96,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase { } /** - * Tests creating a fieldable entity type that doesn't exist in code anymore. - * - * @covers ::installFieldableEntityType - */ - public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Install the entity type and check that its tables have been created. - $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.'); - } - - /** - * Tests updating an entity type that doesn't exist in code anymore. - * - * @covers ::updateEntityType - */ - public function testUpdateEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Add an entity index, update the entity type and check that the index has - // been created. - $this->addEntityIndex(); - $this->entityDefinitionUpdateManager->updateEntityType($entity_type); - - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - } - - /** - * Tests updating a fieldable entity type that doesn't exist in code anymore. - * - * @covers ::updateFieldableEntityType - */ - public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Rename the base table, update the fieldable entity type and check that - // the table has been renamed. - $entity_type->set('base_table', 'entity_test_update_new'); - $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions); - - $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.'); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.'); - } - - /** - * Tests uninstalling an entity type that doesn't exist in code anymore. - * - * @covers ::uninstallEntityType - */ - public function testUninstallEntityTypeWithoutInCodeDefinition(): void { - $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Now uninstall it and check that the tables have been removed. - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); - $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); - } - - /** - * Tests uninstalling a revisionable entity type that doesn't exist in code. - * - * @covers ::uninstallEntityType - */ - public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void { - $this->updateEntityTypeToRevisionable(TRUE); - $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); - - // Remove the entity type definition. This is the same thing as removing the - // code that defines it. - $this->deleteEntityType(); - - // Now uninstall it and check that the tables have been removed. - $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.'); - $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.'); - } - - /** - * Tests creating, updating, and deleting a base field if no entities exist. - */ - public function testBaseFieldCreateUpdateDeleteWithoutData(): void { - // Add a base field, ensure the update manager reports it, and the update - // creates its schema. - $this->addBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); - - // Add an index on the base field, ensure the update manager reports it, - // and the update creates it. - $this->addBaseFieldIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.'); - - // Remove the above index, ensure the update manager reports it, and the - // update deletes it. - $this->removeBaseFieldIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.'); - - // Update the type of the base field from 'string' to 'text', ensure the - // update manager reports it, and the update adjusts the schema - // accordingly. - $this->modifyBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.'); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.'); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.'); - - // Remove the base field, ensure the update manager reports it, and the - // update deletes the schema. - $this->removeBaseField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.'); - $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.'); - } - - /** - * Tests creating, updating, and deleting a base field with no label set. - * - * See testBaseFieldCreateUpdateDeleteWithoutData() for more details - */ - public function testBaseFieldWithoutLabelCreateUpdateDelete(): void { - // Add a base field, ensure the update manager reports it with the - // field id. - $this->addBaseField('string', 'entity_test_update', FALSE, FALSE); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - - // Add an index on the base field, ensure the update manager reports it with - // the field id. - $this->addBaseFieldIndex(); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - - // Remove the base field, ensure the update manager reports it with the - // field id. - $this->removeBaseField(); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - } - - /** - * Tests creating, updating, and deleting a bundle field if no entities exist. - */ - public function testBundleFieldCreateUpdateDeleteWithoutData(): void { - // Add a bundle field, ensure the update manager reports it, and the update - // creates its schema. - $this->addBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); - - // Update the type of the base field from 'string' to 'text', ensure the - // update manager reports it, and the update adjusts the schema - // accordingly. - $this->modifyBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.'); - - // Remove the bundle field, ensure the update manager reports it, and the - // update deletes the schema. - $this->removeBundleField(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $changes = $this->entityDefinitionUpdateManager->getChangeSummary(); - $this->assertCount(1, $changes['entity_test_update']); - $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0])); - $this->applyEntityUpdates(); - $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); - } - - /** - * Tests creating and deleting a base field if entities exist. - * - * This tests deletion when there are existing entities, but non-existent data - * for the field being deleted. - * - * @see testBaseFieldDeleteWithExistingData() - */ - public function testBaseFieldCreateDeleteWithExistingEntities(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Add a base field and run the update. Ensure the base field's column is - // created and the prior saved entity data is still there. - $this->addBaseField(); - $this->applyEntityUpdates(); - $schema_handler = $this->database->schema(); - $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); - - // Remove the base field and run the update. Ensure the base field's column - // is deleted and the prior saved entity data is still there. - $this->removeBaseField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); - - // Add a base field with a required property and run the update. Ensure - // 'not null' is not applied and thus no exception is thrown. - $this->addBaseField('shape_required'); - $this->applyEntityUpdates(); - $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); - $this->assertTrue($assert, 'Columns created in shared table for new_base_field.'); - - // Recreate the field after emptying the base table and check that its - // columns are not 'not null'. - // @todo Revisit this test when allowing for required storage field - // definitions. See https://www.drupal.org/node/2390495. - $entity->delete(); - $this->removeBaseField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.'); - $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.'); - $this->addBaseField('shape_required'); - $this->applyEntityUpdates(); - $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color'); - $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - } - - /** - * Tests creating and deleting a bundle field if entities exist. - * - * This tests deletion when there are existing entities, but non-existent data - * for the field being deleted. - * - * @see testBundleFieldDeleteWithExistingData() - */ - public function testBundleFieldCreateDeleteWithExistingEntities(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Add a bundle field and run the update. Ensure the bundle field's table - // is created and the prior saved entity data is still there. - $this->addBundleField(); - $this->applyEntityUpdates(); - $schema_handler = $this->database->schema(); - $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.'); - - // Remove the base field and run the update. Ensure the bundle field's - // table is deleted and the prior saved entity data is still there. - $this->removeBundleField(); - $this->applyEntityUpdates(); - $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.'); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id()); - $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.'); - - // Test that required columns are created as 'not null'. - $this->addBundleField('shape_required'); - $this->applyEntityUpdates(); - $message = 'The new_bundle_field_shape column is not nullable.'; - $values = [ - 'bundle' => $entity->bundle(), - 'deleted' => 0, - 'entity_id' => $entity->id(), - 'revision_id' => $entity->id(), - 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, - 'delta' => 0, - 'new_bundle_field_color' => $this->randomString(), - ]; - try { - // Try to insert a record without providing a value for the 'not null' - // column. This should fail. - $this->database->insert('entity_test_update__new_bundle_field') - ->fields($values) - ->execute(); - $this->fail($message); - } - catch (IntegrityConstraintViolationException) { - // Now provide a value for the 'not null' column. This is expected to - // succeed. - $values['new_bundle_field_shape'] = $this->randomString(); - $this->database->insert('entity_test_update__new_bundle_field') - ->fields($values) - ->execute(); - } - } - - /** - * Tests deleting a bundle field when it has existing data. - */ - public function testBundleFieldDeleteWithExistingData(): void { - /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */ - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $schema_handler = $this->database->schema(); - - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ - $table_mapping = $storage->getTableMapping(); - $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field']; - - // Check that the bundle field has a dedicated table. - $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition); - $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.'); - - // Save an entity with the bundle field populated. - EntityTestHelper::createBundle('custom'); - $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']); - $entity->save(); - - // Remove the bundle field and apply updates. - $this->removeBundleField(); - $this->applyEntityUpdates(); - - // Check that the table of the bundle field has been renamed to use a - // 'deleted' table name. - $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.'); - - $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE); - $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.'); - - // Check that the deleted field's data is preserved in the dedicated - // 'deleted' table. - $result = $this->database->select($dedicated_deleted_table_name, 't') - ->fields('t') - ->execute() - ->fetchAll(); - $this->assertCount(1, $result); - - $expected = [ - 'bundle' => $entity->bundle(), - 'deleted' => '1', - 'entity_id' => $entity->id(), - 'revision_id' => $entity->id(), - 'langcode' => $entity->language()->getId(), - 'delta' => '0', - 'new_bundle_field_value' => $entity->new_bundle_field->value, - ]; - // Use assertEquals and not assertSame here to prevent that a different - // sequence of the columns in the table will affect the check. - $this->assertEquals($expected, (array) $result[0]); - - // Check that the field definition is marked for purging. - $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); - $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.'); - - // Check that the field storage definition is marked for purging. - $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); - $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.'); - - // Purge field data, and check that the storage definition has been - // completely removed once the data is purged. - field_purge_batch(10); - $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions(); - $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.'); - $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions(); - $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.'); - $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.'); - } - - /** - * Tests updating a base field when it has existing data. - */ - public function testBaseFieldUpdateWithExistingData(): void { - // Add the base field and run the update. - $this->addBaseField(); - $this->applyEntityUpdates(); - - // Save an entity with the base field populated. - $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save(); - - // Change the field's field type and apply updates. It's expected to - // throw an exception. - $this->modifyBaseField(); - try { - $this->applyEntityUpdates(); - $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); - } - catch (FieldStorageDefinitionUpdateForbiddenException) { - // Expected exception; just continue testing. - } - } - - /** - * Tests updating a bundle field when it has existing data. - */ - public function testBundleFieldUpdateWithExistingData(): void { - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - // Save an entity with the bundle field populated. - EntityTestHelper::createBundle('custom'); - $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save(); - - // Change the field's field type and apply updates. It's expected to - // throw an exception. - $this->modifyBundleField(); - try { - $this->applyEntityUpdates(); - $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.'); - } - catch (FieldStorageDefinitionUpdateForbiddenException) { - // Expected exception; just continue testing. - } - } - - /** - * Tests updating a bundle field when the entity type schema has changed. - */ - public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void { - // Add the bundle field and run the update. - $this->addBundleField(); - $this->applyEntityUpdates(); - - // Update the entity type schema to revisionable but don't run the updates - // yet. - $this->updateEntityTypeToRevisionable(); - - // Perform a no-op update on the bundle field, which should work because - // both the storage and the storage schema are using the last installed - // entity type definition. - $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); - $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update')); - } - - /** - * Tests creating and deleting a multi-field index when there are no existing entities. - */ - public function testEntityIndexCreateDeleteWithoutData(): void { - // Add an entity index and ensure the update manager reports that as an - // update to the entity type. - $this->addEntityIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the new index is created. - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - - // Remove the index and ensure the update manager reports that as an - // update to the entity type. - $this->removeEntityIndex(); - $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.'); - $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel(); - $expected = [ - 'entity_test_update' => [ - "The $entity_type entity type needs to be updated.", - ], - ]; - $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.'); - - // Run the update and ensure the index is deleted. - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); - - // Test that composite indexes are handled correctly when dropping and - // re-creating one of their columns. - $this->addEntityIndex(); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - - $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update'); - $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.'); - $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); - $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.'); - } - - /** - * Tests creating a multi-field index when there are existing entities. - */ - public function testEntityIndexCreateWithData(): void { - // Save an entity. - $name = $this->randomString(); - $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]); - $entity->save(); - - // Add an entity index, run the update. Ensure that the index is created - // despite having data. - $this->addEntityIndex(); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.'); - } - - /** - * Tests entity type and field storage definition events. - */ - public function testDefinitionEvents(): void { - /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */ - $event_subscriber = $this->container->get('entity_test.definition.subscriber'); - $event_subscriber->enableEventTracking(); - $event_subscriber->enableLiveDefinitionUpdates(); - - // Test field storage definition events. - $storage_definition = FieldStorageDefinition::create('string') - ->setName('field_storage_test') - ->setLabel(new TranslatableMarkup('Field storage test')) - ->setTargetEntityTypeId('entity_test_rev'); - - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.'); - - // Check that the newly added field can be retrieved from the live field - // storage definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertArrayHasKey('field_storage_test', $field_storage_definitions); - - $updated_storage_definition = clone $storage_definition; - $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test')); - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.'); - - // Check that the updated field can be retrieved from the live field storage - // definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel()); - - $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.'); - \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition); - $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.'); - - // Check that the deleted field can no longer be retrieved from the live - // field storage definitions. - $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev'); - $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions); - - // Test entity type events. - $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); - - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.'); - - $updated_entity_type = clone $entity_type; - $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev')); - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.'); - - // Check that the updated definition can be retrieved from the live entity - // type definitions. - $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev'); - $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel()); - - $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.'); - \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type); - $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.'); - $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.'); - - // Check that the deleted entity type can no longer be retrieved from the - // live entity type definitions. - $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE)); - } - - /** - * Tests applying single updates. - */ - public function testSingleActionCalls(): void { - $db_schema = $this->database->schema(); - - // Ensure that a non-existing entity type cannot be installed. - $message = 'A non-existing entity type cannot be installed'; - try { - $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo'])); - $this->fail($message); - } - catch (PluginNotFoundException) { - // Expected exception; just continue testing. - } - - // Ensure that a field cannot be installed on non-existing entity type. - $message = 'A field cannot be installed on a non-existing entity type'; - try { - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition); - $this->fail($message); - } - catch (PluginNotFoundException) { - // Expected exception; just continue testing. - } - - // Ensure that installing an existing entity type is a no-op. - $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update'); - $this->entityDefinitionUpdateManager->installEntityType($entity_type); - $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op'); - - // Create a new base field. - $this->addRevisionableBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - - // Ensure that installing an existing field is a no-op. - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op'); - - // Update an existing field schema. - $this->modifyBaseField(); - $storage_definition = BaseFieldDefinition::create('text') - ->setName('new_base_field') - ->setTargetEntityTypeId('entity_test_update') - ->setLabel('A new revisionable base field') - ->setRevisionable(TRUE); - $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists."); - $this->assertTrue( - $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), - "New schema for 'new_base_field' has been created." - ); - - // Drop an existing field schema. - $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition); - $this->assertFalse( - $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'), - "The schema for 'new_base_field' has been dropped." - ); - - // Make the entity type revisionable. - $this->updateEntityTypeToRevisionable(); - $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update."); - - $this->updateEntityTypeToRevisionable(TRUE); - $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created."); - } - - /** - * Ensures that a new field and index on a shared table are created. - * - * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema - */ - public function testCreateFieldAndIndexOnSharedTable(): void { - $this->addBaseField(); - $this->addBaseFieldIndex(); - $this->applyEntityUpdates(); - $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); - // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { - $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); - $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); - } - } - - /** - * Ensures that a new entity level index is created when data exists. - * - * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate - */ - public function testCreateIndexUsingEntityStorageSchemaWithData(): void { - // Save an entity. - $name = $this->randomString(); - $storage = $this->entityTypeManager->getStorage('entity_test_update'); - $entity = $storage->create(['name' => $name]); - $entity->save(); - - // Create an index. - $indexes = [ - 'entity_test_update__type_index' => ['type'], - ]; - $this->state->set('entity_test_update.additional_entity_indexes', $indexes); - $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update'); - $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update'); - \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); - - $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); - // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { - $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); - $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); - } - } - - /** - * Tests updating a base field when it has existing data. - */ - public function testBaseFieldEntityKeyUpdateWithExistingData(): void { - // Add the base field and run the update. - $this->addBaseField(); - $this->applyEntityUpdates(); - - // Save an entity with the base field populated. - $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save(); - - // Save an entity with the base field not populated. - /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */ - $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(); - $entity->save(); - - // Promote the base field to an entity key. This will trigger the addition - // of a NOT NULL constraint. - $this->makeBaseFieldEntityKey(); - - // Field storage CRUD operations use the last installed entity type - // definition so we need to update it before doing any other field storage - // updates. - $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type')); - - // Try to apply the update and verify they fail since we have a NULL value. - $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.'; - try { - $this->applyEntityUpdates(); - $this->fail($message); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - - // Check that the update is correctly applied when no NULL data is left. - $entity->set('new_base_field', $this->randomString()); - $entity->save(); - $this->applyEntityUpdates(); - - // Check that the update actually applied a NOT NULL constraint. - $entity->set('new_base_field', NULL); - $message = 'The NOT NULL constraint was correctly applied.'; - try { - $entity->save(); - $this->fail($message); - } - catch (EntityStorageException) { - // Expected exception; just continue testing. - } - } - - /** - * Check that field schema is correctly handled with long-named fields. - */ - public function testLongNameFieldIndexes(): void { - $this->addLongNameBaseField(); - $entity_type_id = 'entity_test_update'; - $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); - $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type); - $name = 'new_long_named_entity_reference_base_field'; - $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]); - $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.'); - } - - /** - * Tests adding a base field with initial values. - */ - public function testInitialValue(): void { - $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); - $db_schema = $this->database->schema(); - - // Create two entities before adding the base field. - $storage->create()->save(); - $storage->create()->save(); - - // Add a base field with an initial value. - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new base field') - ->setInitialValue('test value'); - - $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update."); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); - - // Check that the initial values have been applied. - $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update'); - $entities = $storage->loadMultiple(); - $this->assertEquals('test value', $entities[1]->get('new_base_field')->value); - $this->assertEquals('test value', $entities[2]->get('new_base_field')->value); - } - - /** - * Tests the error handling when using initial values from another field. - */ - public function testInitialValueFromFieldErrorHandling(): void { - // Check that setting invalid values for 'initial value from field' doesn't - // work. - try { - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('string') - ->setLabel('A new base field') - ->setInitialValueFromField('field_that_does_not_exist'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->fail('Using a non-existent field as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage()); - } - - try { - $this->addBaseField(); - $storage_definition = BaseFieldDefinition::create('integer') - ->setLabel('A new base field') - ->setInitialValueFromField('name'); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition); - $this->fail('Using a field of a different type as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage()); - } - - try { - // Add a base field that will not be stored in the shared tables. - $initial_field = BaseFieldDefinition::create('string') - ->setName('initial_field') - ->setLabel('An initial field') - ->setCardinality(2); - $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field); - - // Now add the base field which will try to use the previously added field - // as the source of its initial values. - $new_base_field = BaseFieldDefinition::create('string') - ->setName('new_base_field') - ->setLabel('A new base field') - ->setInitialValueFromField('initial_field'); - $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]); - $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field); - $this->fail('Using a field that is not stored in the shared tables as initial value does not work.'); - } - catch (FieldException $e) { - $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage()); - } - } - - /** * @covers ::getEntityTypes */ public function testGetEntityTypes(): void { diff --git a/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php b/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php new file mode 100644 index 00000000000..020ef21c227 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Extension/LegacyRequirementSeverityTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Extension; + +use Drupal\KernelTests\KernelTestBase; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the legacy requirements severity deprecations. + * + * @coversDefaultClass \Drupal\Core\Extension\Requirement\RequirementSeverity + * @group extension + * @group legacy + */ +class LegacyRequirementSeverityTest extends KernelTestBase { + + /** + * @covers \drupal_requirements_severity + * @dataProvider requirementProvider + */ + public function testGetMaxSeverity(array $requirements, int $expectedSeverity): void { + $this->expectDeprecation( + 'drupal_requirements_severity() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use Drupal\Core\Extension\Requirement\RequirementSeverity::maxSeverityFromRequirements() instead. See https://www.drupal.org/node/3410939' + ); + $this->expectDeprecation( + 'Calling Drupal\Core\Extension\Requirement\RequirementSeverity::maxSeverityFromRequirements() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939' + ); + $severity = drupal_requirements_severity($requirements); + $this->assertEquals($expectedSeverity, $severity); + } + + /** + * Data provider for requirement helper test. + * + * @return array + * Test data. + */ + public static function requirementProvider(): array { + $info = [ + 'title' => 'Foo', + 'severity' => \REQUIREMENT_INFO, + ]; + $warning = [ + 'title' => 'Baz', + 'severity' => \REQUIREMENT_WARNING, + ]; + $error = [ + 'title' => 'Wiz', + 'severity' => \REQUIREMENT_ERROR, + ]; + $ok = [ + 'title' => 'Bar', + 'severity' => \REQUIREMENT_OK, + ]; + + return [ + 'error is most severe' => [ + [ + $info, + $error, + $ok, + ], + \REQUIREMENT_ERROR, + ], + 'ok is most severe' => [ + [ + $info, + $ok, + ], + \REQUIREMENT_OK, + ], + 'warning is most severe' => [ + [ + $warning, + $info, + $ok, + ], + \REQUIREMENT_WARNING, + ], + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php index ffc5a85fdb0..fdfa189b880 100644 --- a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php +++ b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php @@ -17,7 +17,9 @@ use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\NodeType; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\StyleInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Validator\Exception\ValidationFailedException; /** @@ -289,4 +291,32 @@ YAML RecipeRunner::processRecipe($recipe); } + /** + * Tests that the askHidden prompt forwards arguments correctly. + */ + public function testAskHiddenPromptArgumentsForwarded(): void { + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $io = new SymfonyStyle($input, $output); + + $recipe = $this->createRecipe(<<<YAML +name: 'Prompt askHidden Test' +input: + foo: + data_type: string + description: Foo + prompt: + method: askHidden + default: + source: value + value: bar +YAML + ); + $collector = new ConsoleInputCollector($input, $io); + // askHidden prompt should have an ArgumentCountError rather than a general + // error. + $this->expectException(\ArgumentCountError::class); + $recipe->input->collectAll($collector); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php new file mode 100644 index 00000000000..93470153f32 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/LegacyStatusReportTest.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Render\Element; + +use Drupal\Core\Render\Element\StatusReport; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests the status report element legacy methods. + * + * @group Render + * @group legacy + */ +class LegacyStatusReportTest extends KernelTestBase { + + /** + * Tests the getSeverities() method deprecation. + */ + public function testGetSeveritiesDeprecation(): void { + $this->expectDeprecation('Calling Drupal\Core\Render\Element\StatusReport::getSeverities() is deprecated in drupal:11.2.0 and is removed from in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3410939'); + $severities = StatusReport::getSeverities(); + $this->assertIsArray($severities); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php b/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php new file mode 100644 index 00000000000..90e4a9c36be --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Render/Element/StatusReportTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Render\Element; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\Render\Element\StatusReport; +use Drupal\KernelTests\KernelTestBase; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the status report element. + * + * @group Render + * @group legacy + */ +class StatusReportTest extends KernelTestBase { + + /** + * Tests the status report element. + */ + public function testPreRenderGroupRequirements(): void { + $element = [ + '#priorities' => [ + 'error', + 'warning', + 'checked', + 'ok', + ], + '#requirements' => [ + 'foo' => [ + 'title' => 'Foo', + 'severity' => RequirementSeverity::Info, + ], + 'baz' => [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ], + 'wiz' => [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ], + 'bar' => [ + 'title' => 'Bar', + 'severity' => RequirementSeverity::OK, + ], + 'legacy' => [ + 'title' => 'Legacy', + 'severity' => \REQUIREMENT_OK, + ], + ], + ]; + + $this->expectDeprecation('Calling Drupal\Core\Render\Element\StatusReport::preRenderGroupRequirements() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939'); + + $element = StatusReport::preRenderGroupRequirements($element); + $groups = $element['#grouped_requirements']; + + $errors = $groups['error']; + $this->assertEquals('Errors found', (string) $errors['title']); + $this->assertEquals('error', $errors['type']); + $errorItems = $errors['items']; + $this->assertCount(1, $errorItems); + $this->assertArrayHasKey('wiz', $errorItems); + + $warnings = $groups['warning']; + $this->assertEquals('Warnings found', (string) $warnings['title']); + $this->assertEquals('warning', $warnings['type']); + $warningItems = $warnings['items']; + $this->assertCount(1, $warningItems); + $this->assertArrayHasKey('baz', $warningItems); + + $checked = $groups['checked']; + $this->assertEquals('Checked', (string) $checked['title']); + $this->assertEquals('checked', $checked['type']); + $checkedItems = $checked['items']; + $this->assertCount(3, $checkedItems); + $this->assertArrayHasKey('foo', $checkedItems); + $this->assertArrayHasKey('bar', $checkedItems); + $this->assertArrayHasKey('legacy', $checkedItems); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php new file mode 100644 index 00000000000..feb6addef2a --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiFindAllClassFilesTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Test; + +use Drupal\Core\Test\PhpUnitTestDiscovery; +use Drupal\Core\Test\TestDiscovery; +use Drupal\KernelTests\KernelTestBase; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; + +/** + * Tests ::findAllClassFiles() between TestDiscovery and PhpUnitTestDiscovery. + * + * PhpUnitTestDiscovery uses PHPUnit API to build the list of test classes, + * while TestDiscovery uses Drupal legacy code. + */ +#[CoversClass(PhpUnitTestDiscovery::class)] +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] +class PhpUnitApiFindAllClassFilesTest extends KernelTestBase { + + /** + * Checks that Drupal legacy and PHPUnit API based discoveries are equal. + */ + #[DataProvider('argumentsProvider')] + #[IgnoreDeprecations] + public function testEquality(?string $extension = NULL, ?string $directory = NULL): void { + $testDiscovery = new TestDiscovery( + $this->container->getParameter('app.root'), + $this->container->get('class_loader') + ); + $internalList = $testDiscovery->findAllClassFiles($extension, $directory); + + // Location of PHPUnit configuration file. + $configurationFilePath = $this->container->getParameter('app.root') . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + + $phpUnitTestDiscovery = new PhpUnitTestDiscovery($configurationFilePath); + + $phpUnitList = $phpUnitTestDiscovery->findAllClassFiles($extension, $directory); + + // Downgrade results to make them comparable, working around bugs and + // additions. + // 1. TestDiscovery discovers non-test classes that PHPUnit does not. + $internalList = array_intersect_key($internalList, $phpUnitList); + + $this->assertEquals($internalList, $phpUnitList); + } + + /** + * Provides test data to ::testEquality. + */ + public static function argumentsProvider(): \Generator { + yield 'All tests' => []; + yield 'Extension: system' => ['extension' => 'system']; + yield 'Extension: system, directory' => [ + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php new file mode 100644 index 00000000000..32d21e3e6c3 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitApiGetTestClassesTest.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Test; + +use Drupal\Core\Test\PhpUnitTestDiscovery; +use Drupal\Core\Test\TestDiscovery; +use Drupal\KernelTests\KernelTestBase; +use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; + +/** + * Tests ::getTestClasses() between TestDiscovery and PhpPUnitTestDiscovery. + * + * PhpPUnitTestDiscovery uses PHPUnit API to build the list of test classes, + * while TestDiscovery uses Drupal legacy code. + */ +#[CoversClass(PhpUnitTestDiscovery::class)] +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] +class PhpUnitApiGetTestClassesTest extends KernelTestBase { + + /** + * Checks that Drupal legacy and PHPUnit API based discoveries are equal. + */ + #[DataProvider('argumentsProvider')] + #[IgnoreDeprecations] + public function testEquality(array $suites, ?string $extension = NULL, ?string $directory = NULL): void { + $testDiscovery = new TestDiscovery( + $this->container->getParameter('app.root'), + $this->container->get('class_loader') + ); + $internalList = $testDiscovery->getTestClasses($extension, $suites, $directory); + + // Location of PHPUnit configuration file. + $configurationFilePath = $this->container->getParameter('app.root') . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + + $phpUnitTestDiscovery = new PhpUnitTestDiscovery($configurationFilePath); + + $phpUnitList = $phpUnitTestDiscovery->getTestClasses($extension, $suites, $directory); + + // Downgrade results to make them comparable, working around bugs and + // additions. + // 1. Remove TestDiscovery empty groups. + $internalList = array_filter($internalList); + // 2. Remove TestDiscovery '##no-group-annotations' group. + unset($internalList['##no-group-annotations']); + // 3. Remove 'file' and 'tests_count' keys from PHPUnit results. + foreach ($phpUnitList as &$group) { + foreach ($group as &$testClass) { + unset($testClass['file']); + unset($testClass['tests_count']); + } + } + // 4. Remove from PHPUnit results groups not found by TestDiscovery. + $phpUnitList = array_intersect_key($phpUnitList, $internalList); + // 5. Remove from PHPUnit groups classes not found by TestDiscovery. + foreach ($phpUnitList as $groupName => &$group) { + $group = array_intersect_key($group, $internalList[$groupName]); + } + // 6. Remove from PHPUnit test classes groups not found by TestDiscovery. + foreach ($phpUnitList as $groupName => &$group) { + foreach ($group as $testClassName => &$testClass) { + $testClass['groups'] = array_intersect_key($testClass['groups'], $internalList[$groupName][$testClassName]['groups']); + } + } + + $this->assertEquals($internalList, $phpUnitList); + } + + /** + * Provides test data to ::testEquality. + */ + public static function argumentsProvider(): \Generator { + yield 'All tests' => ['suites' => []]; + yield 'Testsuite: functional-javascript' => ['suites' => ['PHPUnit-FunctionalJavascript']]; + yield 'Testsuite: functional' => ['suites' => ['PHPUnit-Functional']]; + yield 'Testsuite: kernel' => ['suites' => ['PHPUnit-Kernel']]; + yield 'Testsuite: unit' => ['suites' => ['PHPUnit-Unit']]; + yield 'Testsuite: unit-component' => ['suites' => ['PHPUnit-Unit-Component']]; + yield 'Testsuite: build' => ['suites' => ['PHPUnit-Build']]; + yield 'Extension: system' => ['suites' => [], 'extension' => 'system']; + yield 'Extension: system, testsuite: unit' => [ + 'suites' => ['PHPUnit-Unit'], + 'extension' => 'system', + ]; + yield 'Extension: system, directory' => [ + 'suites' => [], + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + yield 'Extension: system, testsuite: unit, directory' => [ + 'suites' => ['PHPUnit-Unit'], + 'extension' => 'system', + 'directory' => 'core/modules/system/tests/src', + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php index 705981f7507..d8534458049 100644 --- a/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php +++ b/core/tests/Drupal/KernelTests/Core/Test/PhpUnitTestDiscoveryTest.php @@ -7,6 +7,8 @@ namespace Drupal\KernelTests\Core\Test; use Drupal\Core\Test\TestDiscovery; use Drupal\KernelTests\KernelTestBase; use Drupal\TestTools\PhpUnitCompatibility\RunnerVersion; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\TextUI\Configuration\Builder; use PHPUnit\TextUI\Configuration\TestSuiteBuilder; use Symfony\Component\Process\Process; @@ -22,11 +24,10 @@ use Symfony\Component\Process\Process; * list thus generated, with the list generated by * \Drupal\Core\Test\TestDiscovery, which is used by run-tests.sh, to ensure * both methods will run the same tests, - * - * @group TestSuites - * @group Test - * @group #slow */ +#[Group('TestSuites')] +#[Group('Test')] +#[Group('#slow')] class PhpUnitTestDiscoveryTest extends KernelTestBase { private const TEST_LIST_MISMATCH_MESSAGE = @@ -61,6 +62,7 @@ class PhpUnitTestDiscoveryTest extends KernelTestBase { /** * Tests equality of test discovery between run-tests.sh and PHPUnit CLI. */ + #[IgnoreDeprecations] public function testPhpUnitTestDiscoveryEqualsInternal(): void { // Drupal's test discovery, used by run-tests.sh. $testDiscovery = new TestDiscovery( @@ -76,11 +78,19 @@ class PhpUnitTestDiscoveryTest extends KernelTestBase { $internalList = array_unique($internalList); asort($internalList); + // Location of PHPUnit configuration file. + $configurationFilePath = $this->root . \DIRECTORY_SEPARATOR . 'core'; + // @todo once PHPUnit 10 is no longer used, remove the condition. + // @see https://www.drupal.org/project/drupal/issues/3497116 + if (RunnerVersion::getMajor() >= 11) { + $configurationFilePath .= \DIRECTORY_SEPARATOR . '.phpunit-next.xml'; + } + // PHPUnit's test discovery - via CLI execution. $process = new Process([ 'vendor/bin/phpunit', '--configuration', - 'core', + $configurationFilePath, '--list-tests-xml', $this->xmlOutputFile, ], $this->root); @@ -113,14 +123,8 @@ class PhpUnitTestDiscoveryTest extends KernelTestBase { // Check against Drupal's discovery. $this->assertEquals(implode("\n", $phpUnitClientList), implode("\n", $internalList), self::TEST_LIST_MISMATCH_MESSAGE); - // @todo once PHPUnit 10 is no longer used re-enable the rest of the test. - // @see https://www.drupal.org/project/drupal/issues/3497116 - if (RunnerVersion::getMajor() >= 11) { - $this->markTestIncomplete('On PHPUnit 11+ the test triggers warnings due to phpunit.xml setup. Re-enable in https://www.drupal.org/project/drupal/issues/3497116.'); - } - // PHPUnit's test discovery - via API. - $phpUnitConfiguration = (new Builder())->build(['--configuration', 'core']); + $phpUnitConfiguration = (new Builder())->build(['--configuration', $configurationFilePath]); $phpUnitTestSuite = (new TestSuiteBuilder())->build($phpUnitConfiguration); $phpUnitApiList = []; foreach ($phpUnitTestSuite->tests() as $testSuite) { diff --git a/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php b/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php index f2f9bd16a04..cd4fdc1258c 100644 --- a/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php +++ b/core/tests/Drupal/KernelTests/Core/Updater/UpdateRequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\KernelTests\Core\Updater; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\KernelTests\KernelTestBase; @@ -27,7 +28,7 @@ class UpdateRequirementsTest extends KernelTestBase { 'title' => 'UpdateError', 'value' => 'None', 'description' => 'Update Error.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; $requirements = update_check_requirements()['test.update.error']; $this->assertEquals($testRequirements, $requirements); @@ -36,7 +37,7 @@ class UpdateRequirementsTest extends KernelTestBase { 'title' => 'UpdateWarning', 'value' => 'None', 'description' => 'Update Warning.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; $alterRequirements = update_check_requirements()['test.update.error.alter']; $this->assertEquals($testAlterRequirements, $alterRequirements); diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js new file mode 100644 index 00000000000..98916702a88 --- /dev/null +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxTest.js @@ -0,0 +1,77 @@ +// The javascript that creates dropbuttons is not present on the /page at +// initial load. If the once data property is added then the JS was loaded +// and triggered on the inserted content. +// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController +// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js + +const scriptSelector = 'script[src*="test_htmx/js/behavior.js"]'; +const cssSelector = 'link[rel="stylesheet"][href*="test_htmx/css/style.css"]'; +const elementSelector = '.ajax-content'; +const elementInitSelector = `${elementSelector}[data-once="htmx-init"]`; + +module.exports = { + '@tags': ['core', 'htmx'], + before(browser) { + browser.drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + installProfile: 'minimal', + }); + }, + afterEach(browser) { + browser.drupalLogAndEnd({ onlyOnError: true }); + }, + after(browser) { + browser.drupalUninstall(); + }, + + 'Asset Load': (browser) => { + // Load the route htmx will use for the request on click and confirm the + // markup we will be looking for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent(elementInitSelector); + // Now load the page with the htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript and markup. + browser + .drupalRelativeURL('/htmx-test-attachments/page') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent(scriptSelector) + .assert.not.elementPresent(cssSelector) + .waitForElementVisible('[name="replace"]', 1000) + .click('[name="replace"]') + .waitForElementVisible(elementSelector, 6000) + .waitForElementVisible(elementInitSelector, 6000) + .assert.elementPresent(scriptSelector) + .assert.elementPresent(cssSelector); + }, + + 'Ajax Load HTMX Element': (browser) => { + // Load the route htmx will use for the request on click and confirm the + // markup we will be looking for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent(scriptSelector); + // Now load the page with the ajax powered button. Click the button + // to insert an htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript and markup. + browser + .drupalRelativeURL('/htmx-test-attachments/ajax') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent(scriptSelector) + .assert.not.elementPresent(cssSelector) + .waitForElementVisible('[data-drupal-selector="edit-ajax-button"]', 1000) + .pause(1000) + .click('[data-drupal-selector="edit-ajax-button"]') + .waitForElementVisible('[name="replace"]', 1000) + .pause(1000) + .click('[name="replace"]') + .waitForElementVisible(elementSelector, 6000) + .waitForElementVisible(elementInitSelector, 6000) + .assert.elementPresent(scriptSelector) + .assert.elementPresent(cssSelector); + }, +}; diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php new file mode 100644 index 00000000000..03bb3fbdb11 --- /dev/null +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\TestSite; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\Core\Extension\ThemeInstallerInterface; + +/** + * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js. + * + * @see \Drupal\Tests\Scripts\TestSiteApplicationTest + */ +class HtmxAssetLoadTestSetup implements TestSetupInterface { + + /** + * {@inheritdoc} + */ + public function setup(): void { + // Install Olivero and set it as the default theme. + $theme_installer = \Drupal::service('theme_installer'); + assert($theme_installer instanceof ThemeInstallerInterface); + $theme_installer->install(['olivero'], TRUE); + $system_theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $system_theme_config->set('default', 'olivero')->save(); + + // Install required modules. + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['test_htmx']); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php index f9b25e8e707..16fde578125 100644 --- a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php @@ -910,7 +910,7 @@ class ConnectionTest extends UnitTestCase { #[IgnoreDeprecations] #[DataProvider('providerSupportedLegacyFetchModes')] public function testSupportedLegacyFetchModes(int $mode): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $mockPdo = $this->createMock(StubPDO::class); $mockConnection = new StubConnection($mockPdo, []); $statement = new StatementPrefetchIterator($mockPdo, $mockConnection, ''); @@ -921,7 +921,7 @@ class ConnectionTest extends UnitTestCase { /** * Provides data for testSupportedFetchModes. * - * @return array<string,array<\Drupal\Core\Database\FetchAs>> + * @return array<string,array<\Drupal\Core\Database\Statement\FetchAs>> * The FetchAs cases. */ public static function providerSupportedFetchModes(): array { @@ -975,7 +975,7 @@ class ConnectionTest extends UnitTestCase { #[IgnoreDeprecations] #[DataProvider('providerUnsupportedFetchModes')] public function testUnsupportedFetchModes(int $mode): void { - $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); + $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches("/^Fetch mode FETCH_.* is not supported\\. Use supported modes only/"); $mockPdo = $this->createMock(StubPDO::class); diff --git a/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php b/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php new file mode 100644 index 00000000000..f91af07ddcb --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Extension/Requirement/RequirementSeverityTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Extension\Requirement; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Extension\Requirement\RequirementSeverity + * + * @group Extension + */ +class RequirementSeverityTest extends UnitTestCase { + + /** + * @covers ::convertLegacyIntSeveritiesToEnums + * @group legacy + */ + public function testConvertLegacySeverities(): void { + $requirements['foo'] = [ + 'title' => new TranslatableMarkup('Foo'), + 'severity' => \REQUIREMENT_INFO, + ]; + $requirements['bar'] = [ + 'title' => new TranslatableMarkup('Bar'), + 'severity' => \REQUIREMENT_ERROR, + ]; + $this->expectDeprecation( + 'Calling ' . __METHOD__ . '() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939' + ); + RequirementSeverity::convertLegacyIntSeveritiesToEnums($requirements, __METHOD__); + $this->assertEquals( + RequirementSeverity::Info, + $requirements['foo']['severity'] + ); + $this->assertEquals( + RequirementSeverity::Error, + $requirements['bar']['severity'] + ); + } + + /** + * @covers ::maxSeverityFromRequirements + * @dataProvider requirementProvider + */ + public function testGetMaxSeverity(array $requirements, RequirementSeverity $expectedSeverity): void { + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); + $this->assertEquals($expectedSeverity, $severity); + } + + /** + * Data provider for requirement helper test. + */ + public static function requirementProvider(): array { + $info = [ + 'title' => 'Foo', + 'severity' => RequirementSeverity::Info, + ]; + $warning = [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ]; + $error = [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ]; + $ok = [ + 'title' => 'Bar', + 'severity' => RequirementSeverity::OK, + ]; + + return [ + 'error is most severe' => [ + [ + $info, + $error, + $ok, + ], + RequirementSeverity::Error, + ], + 'ok is most severe' => [ + [ + $info, + $ok, + ], + RequirementSeverity::OK, + ], + 'warning is most severe' => [ + [ + $warning, + $info, + $ok, + ], + RequirementSeverity::Warning, + ], + ]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php b/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php index 08fa2eceaf5..d0759a4bf08 100644 --- a/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/LocalActionManagerTest.php @@ -203,6 +203,8 @@ class LocalActionManagerTest extends UnitTestCase { } public static function getActionsForRouteProvider() { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens(Argument::any()) ->willReturn(TRUE); @@ -384,6 +386,11 @@ class LocalActionManagerTest extends UnitTestCase { ], ]; + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + return $data; } diff --git a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php index 1e75a2e3f4b..5b1a76fbf7e 100644 --- a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Menu\MenuActiveTrail; use Drupal\Core\Routing\CurrentRouteMatch; use Drupal\Tests\UnitTestCase; use Drupal\TestTools\Random; +use Drupal\Core\Path\PathMatcherInterface; use Drupal\Core\Routing\RouteObjectInterface; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\DependencyInjection\Container; @@ -68,6 +69,12 @@ class MenuActiveTrailTest extends UnitTestCase { */ protected $lock; + + /** + * The mocked path matcher. + */ + protected PathMatcherInterface $pathMatcher; + /** * The mocked cache tags invalidator. * @@ -86,9 +93,10 @@ class MenuActiveTrailTest extends UnitTestCase { $this->menuLinkManager = $this->createMock('Drupal\Core\Menu\MenuLinkManagerInterface'); $this->cache = $this->createMock('\Drupal\Core\Cache\CacheBackendInterface'); $this->lock = $this->createMock('\Drupal\Core\Lock\LockBackendInterface'); + $this->pathMatcher = $this->createMock('\Drupal\Core\Path\PathMatcherInterface'); $this->cacheTagsInvalidator = $this->createMock('\Drupal\Core\Cache\CacheTagsInvalidatorInterface'); - $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch, $this->cache, $this->lock); + $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch, $this->cache, $this->lock, $this->pathMatcher); $container = new Container(); $container->set('cache_tags.invalidator', $this->cacheTagsInvalidator); @@ -105,6 +113,7 @@ class MenuActiveTrailTest extends UnitTestCase { * - links: An array of menu links keyed by ID. * - menu_name: The active menu name. * - expected_link: The expected active link for the given menu. + * - expected_trail: The expected active trail for the given menu. */ public static function provider() { $data = []; @@ -167,6 +176,42 @@ class MenuActiveTrailTest extends UnitTestCase { } /** + * Tests that getActiveLink() returns a <front> route link for a route that is the front page and has no other links. + * + * @covers ::getActiveLink + */ + public function testGetActiveLinkReturnsFrontPageLinkAtTheFrontPage(): void { + + // Mock the request. + $mock_route = new Route(''); + $request = new Request(); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'link_1'); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $mock_route); + $request->attributes->set('_raw_variables', new InputBag([])); + $this->requestStack->push($request); + + // Pretend that the current path is the front page. + $this->pathMatcher + ->method('isFrontPage') + ->willReturn(TRUE); + + // Make 'link_1' route to have no links and the '<front>' route to have a link. + $home_link = MenuLinkMock::create(['id' => 'home_link', 'route_name' => 'home_link', 'title' => 'Home', 'parent' => NULL]); + $this->menuLinkManager + ->method('loadLinksByRoute') + ->willReturnCallback(function ($route_name) use ($home_link) { + return match ($route_name) { + 'link_1' => [], + '<front>' => [$home_link], + }; + }); + + // Test. + $this->assertSame($home_link, $this->menuActiveTrail->getActiveLink()); + + } + + /** * Tests getActiveTrailIds(). * * @covers ::getActiveTrailIds diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 69301eae9bb..9c68273365b 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -1097,7 +1097,7 @@ class RendererTest extends RendererTestBase { 'max-age' => 600, ], ], - new \stdClass(), + (new CacheableMetadata())->setCacheMaxAge(0), [ '#cache' => [ 'contexts' => ['theme'], diff --git a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php b/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php deleted file mode 100644 index 6ab42a6c41a..00000000000 --- a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php +++ /dev/null @@ -1,43 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\Core\Template; - -use Twig\Source; -use Twig\Template; - -/** - * A stub of the Twig Template class for testing. - */ -class StubTwigTemplate extends Template { - - /** - * {@inheritdoc} - */ - public function getTemplateName(): string { - return ''; - } - - /** - * {@inheritdoc} - */ - public function getDebugInfo(): array { - return []; - } - - /** - * {@inheritdoc} - */ - public function getSourceContext(): Source { - throw new \LogicException(__METHOD__ . '() not implemented.'); - } - - /** - * {@inheritdoc} - */ - protected function doDisplay(array $context, array $blocks = []): iterable { - throw new \LogicException(__METHOD__ . '() not implemented.'); - } - -} diff --git a/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php b/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php index 957b2f61f97..9138d54523d 100644 --- a/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php +++ b/core/tests/Drupal/Tests/Core/Test/RunTests/TestFileParserTest.php @@ -6,12 +6,18 @@ namespace Drupal\Tests\Core\Test\RunTests; use Drupal\Core\Test\RunTests\TestFileParser; use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** - * @coversDefaultClass \Drupal\Core\Test\RunTests\TestFileParser - * @group Test - * @group RunTests + * Tests for the deprecated TestFileParser class. */ +#[CoversClass(TestFileParser::class)] +#[Group('Test')] +#[Group('RunTest')] +#[IgnoreDeprecations] class TestFileParserTest extends UnitTestCase { public static function provideTestFileContents() { @@ -66,9 +72,9 @@ COMPOUND } /** - * @covers ::parseContents - * @dataProvider provideTestFileContents + * @legacy-covers ::parseContents */ + #[DataProvider('provideTestFileContents')] public function testParseContents($expected, $contents): void { $parser = new TestFileParser(); @@ -78,7 +84,7 @@ COMPOUND } /** - * @covers ::getTestListFromFile + * @legacy-covers ::getTestListFromFile */ public function testGetTestListFromFile(): void { $parser = new TestFileParser(); diff --git a/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php index bfbb4ca2e40..0fb55e6c7f8 100644 --- a/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php +++ b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php @@ -13,17 +13,23 @@ use Drupal\Core\Test\Exception\MissingGroupException; use Drupal\Core\Test\TestDiscovery; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** - * @coversDefaultClass \Drupal\Core\Test\TestDiscovery - * @group Test + * Unit tests for TestDiscovery. */ +#[CoversClass(TestDiscovery::class)] +#[Group('Test')] +#[IgnoreDeprecations] class TestDiscoveryTest extends UnitTestCase { /** - * @covers ::getTestInfo - * @dataProvider infoParserProvider + * @legacy-covers ::getTestInfo */ + #[DataProvider('infoParserProvider')] public function testTestInfoParser($expected, $classname, $doc_comment = NULL): void { $info = TestDiscovery::getTestInfo($classname, $doc_comment); $this->assertEquals($expected, $info); @@ -34,14 +40,14 @@ class TestDiscoveryTest extends UnitTestCase { $tests[] = [ // Expected result. [ - 'name' => static::class, + 'name' => TestDatabaseTest::class, 'group' => 'Test', - 'groups' => ['Test'], - 'description' => 'Tests \Drupal\Core\Test\TestDiscovery.', + 'groups' => ['Test', 'simpletest', 'Template'], + 'description' => 'Tests \Drupal\Core\Test\TestDatabase.', 'type' => 'PHPUnit-Unit', ], // Classname. - static::class, + TestDatabaseTest::class, ]; // A core unit test. @@ -217,7 +223,7 @@ class TestDiscoveryTest extends UnitTestCase { } /** - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testTestInfoParserMissingGroup(): void { $classname = 'Drupal\KernelTests\field\BulkDeleteTest'; @@ -232,7 +238,7 @@ EOT; } /** - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testTestInfoParserMissingSummary(): void { $classname = 'Drupal\KernelTests\field\BulkDeleteTest'; @@ -311,7 +317,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestClasses(): void { $this->setupVfsWithTestClasses(); @@ -380,7 +386,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestClassesWithSelectedTypes(): void { $this->setupVfsWithTestClasses(); @@ -425,7 +431,7 @@ EOF; } /** - * @covers ::getTestClasses + * @legacy-covers ::getTestClasses */ public function testGetTestsInProfiles(): void { $this->setupVfsWithTestClasses(); @@ -454,9 +460,9 @@ EOF; } /** - * @covers ::getPhpunitTestSuite - * @dataProvider providerTestGetPhpunitTestSuite + * @legacy-covers ::getPhpunitTestSuite */ + #[DataProvider('providerTestGetPhpunitTestSuite')] public function testGetPhpunitTestSuite($classname, $expected): void { $this->assertEquals($expected, TestDiscovery::getPhpunitTestSuite($classname)); } @@ -482,7 +488,7 @@ EOF; /** * Ensure that classes are not reflected when the docblock is empty. * - * @covers ::getTestInfo + * @legacy-covers ::getTestInfo */ public function testGetTestInfoEmptyDocblock(): void { // If getTestInfo() performed reflection, it won't be able to find the @@ -497,7 +503,7 @@ EOF; /** * Ensure TestDiscovery::scanDirectory() ignores certain abstract file types. * - * @covers ::scanDirectory + * @legacy-covers ::scanDirectory */ public function testScanDirectoryNoAbstract(): void { $this->setupVfsWithTestClasses(); diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php new file mode 100644 index 00000000000..c39827ca709 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Theme\Component; + +use Drupal\Core\Plugin\Component; +use Drupal\Core\Template\Loader\ComponentLoader; +use Drupal\Core\Theme\ComponentPluginManager; +use Drupal\Tests\UnitTestCaseTest; +use org\bovigo\vfs\vfsStream; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for the component loader class. + * + * @coversDefaultClass \Drupal\Core\Template\Loader\ComponentLoader + * @group sdc + */ +class ComponentLoaderTest extends UnitTestCaseTest { + + /** + * Tests the is fresh function for component loader. + */ + public function testIsFresh(): void { + $vfs_root = vfsStream::setup(); + $component_directory = vfsStream::newDirectory('loader-test')->at($vfs_root); + $current_time = time(); + $component_twig_file = vfsStream::newFile('loader-test.twig') + ->at($component_directory) + ->setContent('twig') + // Mark files as changed before the current time. + ->lastModified($current_time - 1000); + $component_yml_file = vfsStream::newFile('loader-test.component.yml') + ->at($component_directory) + ->setContent('') + // Mark file as changed before the current time. + ->lastModified($current_time - 1000); + + $component = new Component( + ['app_root' => '/fake/root'], + 'sdc_test:loader-test', + [ + 'machineName' => 'loader-test', + 'extension_type' => 'module', + 'id' => 'sdc_test:loader-test', + 'path' => 'vfs://' . $component_directory->path(), + 'provider' => 'sdc_test', + 'template' => 'loader-test.twig', + 'group' => 'my-group', + 'description' => 'My description', + '_discovered_file_path' => 'vfs://' . $component_yml_file->path(), + ] + ); + + $component_manager = $this->prophesize(ComponentPluginManager::class); + $component_manager->find('sdc_test:loader-test')->willReturn($component); + $component_loader = new ComponentLoader( + $component_manager->reveal(), + $this->createMock(LoggerInterface::class), + ); + + // Assert the component is fresh, as it changed before the current time. + $this->assertTrue($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be fresh'); + // Pretend that we changed the twig file. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig file was supposed to be outdated'); + + // Pretend that we changed the YAML file. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time); + $component_yml_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'YAML file was supposed to be outdated'); + + // Pretend that we changed both files. + // It shouldn't matter that the time is in "future". + $component_twig_file->lastModified($current_time + 1000); + $component_yml_file->lastModified($current_time + 1000); + // Clear stat cache, to make sure component loader gets updated time. + clearstatcache(); + // Component shouldn't be "fresh" anymore. + $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be outdated'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php index 84d87390552..46d77602c39 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\Component\ComponentValidator; use Drupal\Core\Render\Component\Exception\InvalidComponentException; use Drupal\Core\Plugin\Component; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\Factory; use JsonSchema\Constraints\FormatConstraint; use JsonSchema\Entity\JsonPointer; @@ -124,6 +125,19 @@ class ComponentValidatorTest extends TestCase { ]; yield 'invalid slot (type)' => [$cta_with_invalid_slot_type]; + $cta_with_invalid_slot_name = $valid_cta; + $cta_with_invalid_slot_name['slots'] = [ + 'valid_slot' => [ + 'title' => 'Valid slot', + 'description' => 'Valid slot description', + ], + 'invalid slot' => [ + 'title' => 'Invalid slot', + 'description' => 'Slot name cannot have spaces', + ], + ]; + yield 'invalid slot (name with spaces)' => [$cta_with_invalid_slot_name]; + $cta_with_invalid_variant_title_type = $valid_cta; $cta_with_invalid_variant_title_type['variants'] = [ 'valid_variant' => [ @@ -346,7 +360,13 @@ class UrlHelperFormatConstraint extends FormatConstraint { } if ($schema->format === 'uri') { if (\is_string($element) && !UrlHelper::isValid($element)) { - $this->addError($path, 'Invalid URL format', 'format', ['format' => $schema->format]); + if (class_exists(ConstraintError::class)) { + $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); + } + else { + // @todo Remove when we no longer support justinrainbow/json-schema v5. + $this->addError($path, 'Invalid URL format', 'format', ['format' => $schema->format]); + } } return; } diff --git a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php index 0f1146f84d8..59bce779111 100644 --- a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Theme\Icon\Plugin; // cspell:ignore corge -use Drupal\Component\Render\FormattableMarkup; + use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\Icon\IconDefinition; use Drupal\Core\Theme\Icon\IconDefinitionInterface; @@ -288,7 +288,6 @@ class SvgExtractorTest extends UnitTestCase { $this->assertInstanceOf(IconDefinitionInterface::class, $icon_loaded); $data_loaded = $icon_loaded->getAllData(); - $expected_content[$index] = new FormattableMarkup($expected_content[$index], []); $this->assertEquals($expected_content[$index], $data_loaded['content']); $expected_attributes[$index] = new Attribute($expected_attributes[$index] ?? []); diff --git a/core/themes/claro/css/classy/components/tablesort.css b/core/themes/claro/css/classy/components/tablesort.css index 44e5349404d..f2a3c4ad60a 100644 --- a/core/themes/claro/css/classy/components/tablesort.css +++ b/core/themes/claro/css/classy/components/tablesort.css @@ -6,6 +6,3 @@ th.is-active img { display: inline; } -td.is-active { - background-color: #ddd; -} diff --git a/core/themes/claro/css/components/breadcrumb.pcss.css b/core/themes/claro/css/components/breadcrumb.pcss.css index 2f9790a5a14..320fc2c67d1 100644 --- a/core/themes/claro/css/components/breadcrumb.pcss.css +++ b/core/themes/claro/css/components/breadcrumb.pcss.css @@ -29,7 +29,7 @@ padding: 0 0.75rem; content: url(../../images/icons/currentColor/arrow-breadcrumb.svg); - @nest [dir="rtl"] & { + [dir="rtl"] & { transform: scaleX(-1); } diff --git a/core/themes/claro/css/components/card.pcss.css b/core/themes/claro/css/components/card.pcss.css index b7de858f6c7..2f3db1770f9 100644 --- a/core/themes/claro/css/components/card.pcss.css +++ b/core/themes/claro/css/components/card.pcss.css @@ -143,7 +143,7 @@ margin-block: 0; text-align: right; /* LTR */ - @nest [dir="rtl"] & { + [dir="rtl"] & { text-align: left; } } diff --git a/core/themes/claro/css/components/details.css b/core/themes/claro/css/components/details.css index d5a34caee31..9f7f852c19e 100644 --- a/core/themes/claro/css/components/details.css +++ b/core/themes/claro/css/components/details.css @@ -176,6 +176,7 @@ td .claro-details { border-inline-end: 0.125rem solid; background: none; } + [dir="rtl"] .claro-details__summary::before { transform: rotate(-225deg); } diff --git a/core/themes/claro/css/components/details.pcss.css b/core/themes/claro/css/components/details.pcss.css index a83aa729425..274386bded0 100644 --- a/core/themes/claro/css/components/details.pcss.css +++ b/core/themes/claro/css/components/details.pcss.css @@ -49,7 +49,7 @@ * element constrains the width. This can happen when toggling the * "lazy-load" option within an image field. */ - @nest td & { + td & { width: min-content; min-width: 100%; } @@ -142,7 +142,7 @@ background-image: url(../../images/icons/545560/chevron-right.svg); background-size: contain; - @nest [dir="rtl"] & { + [dir="rtl"] & { transform: rotate(-270deg); } } @@ -165,7 +165,7 @@ border-inline-end: 0.125rem solid; background: none; - @nest [dir="rtl"] & { + [dir="rtl"] & { transform: rotate(-225deg); } } @@ -310,7 +310,7 @@ border-width: 0 0 0 var(--details-summary-focus-border-size); /* LTR */ box-shadow: none; - @nest [dir="rtl"] & { + [dir="rtl"] & { border-width: 0 var(--details-summary-focus-border-size) 0 0; } } diff --git a/core/themes/claro/css/components/dialog.css b/core/themes/claro/css/components/dialog.css index e1d0b18f3bc..ecaf17d2daa 100644 --- a/core/themes/claro/css/components/dialog.css +++ b/core/themes/claro/css/components/dialog.css @@ -96,7 +96,8 @@ @media (forced-colors: active) { .ui-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close .ui-icon.ui-icon-closethick { - background: url("data:image/svg+xml,%3csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M11 1.318l-10 10M11 11.318l-10-10' stroke='buttonText' stroke-width='1.5'/%3e%3c/svg%3e") no-repeat 50%; + background: buttontext; + mask: url("data:image/svg+xml,%3csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M11 1.318l-10 10M11 11.318l-10-10' stroke='%23D3D4D9' stroke-width='1.5'/%3e%3c/svg%3e") no-repeat 50%; } } diff --git a/core/themes/claro/css/components/dialog.pcss.css b/core/themes/claro/css/components/dialog.pcss.css index ffec289fbc4..a965222cf7c 100644 --- a/core/themes/claro/css/components/dialog.pcss.css +++ b/core/themes/claro/css/components/dialog.pcss.css @@ -86,7 +86,8 @@ background: url(../../images/icons/d3d4d9/ex.svg) no-repeat 50%; @media (forced-colors: active) { - background: url(../../images/icons/buttonText/ex.svg) no-repeat 50%; + background: buttontext; + mask: url(../../images/icons/d3d4d9/ex.svg) no-repeat 50%; } } } diff --git a/core/themes/claro/css/components/form--checkbox-radio.pcss.css b/core/themes/claro/css/components/form--checkbox-radio.pcss.css index 94bcd446294..27144dbea20 100644 --- a/core/themes/claro/css/components/form--checkbox-radio.pcss.css +++ b/core/themes/claro/css/components/form--checkbox-radio.pcss.css @@ -21,7 +21,7 @@ margin-inline-start: calc(var(--input--label-spacing) * -1); transform: translateY(-50%); - @nest [dir="rtl"] & { + [dir="rtl"] & { float: right; } } diff --git a/core/themes/claro/css/components/form--managed-file.pcss.css b/core/themes/claro/css/components/form--managed-file.pcss.css index ecfcf4e9c6c..da1e2a27df9 100644 --- a/core/themes/claro/css/components/form--managed-file.pcss.css +++ b/core/themes/claro/css/components/form--managed-file.pcss.css @@ -79,7 +79,7 @@ } } - @nest .draggable .form-managed-file.has-value & { + .draggable .form-managed-file.has-value & { /** * In tables, this should be inline-flex. This is needed to make this element be * pushed to a new line, to the bottom of the drag handle. @@ -98,7 +98,7 @@ max-width: 100%; margin-block-end: var(--space-m); - @nest .form-managed-file.has-meta & { + .form-managed-file.has-meta & { /* Add some 'end' margin if there are other meta inputs. */ margin-inline-end: var(--space-m); } @@ -107,7 +107,7 @@ * If this is rendered inside a file multiple table and there are no alt or * title, we have to reduce the amount of the bottom margin. */ - @nest td .form-managed-file.no-meta & { + td .form-managed-file.no-meta & { margin-block-end: var(--space-xs); } } @@ -164,11 +164,11 @@ } /* Add some bottom margin for single widgets if no meta is present. */ - @nest .form-managed-file.is-single.has-value &:last-child { + .form-managed-file.is-single.has-value &:last-child { margin-block-end: var(--space-m); } - @nest .draggable .form-managed-file.has-value & { + .draggable .form-managed-file.has-value & { /** * Inside (draggable) tables, this should be flex-displayed. This keeps even * long file names in the same visual line where the drag handle is. diff --git a/core/themes/claro/css/components/form--password-confirm.pcss.css b/core/themes/claro/css/components/form--password-confirm.pcss.css index a013469cbc7..1d7d856e907 100644 --- a/core/themes/claro/css/components/form--password-confirm.pcss.css +++ b/core/themes/claro/css/components/form--password-confirm.pcss.css @@ -21,7 +21,7 @@ .password-confirm__confirm { margin-block-end: 0; - @nest .js & { + .js & { max-height: 10rem; transition: max-height var(--speed-transition) ease-in-out, @@ -125,7 +125,7 @@ font-size: var(--progress-bar-description-font-size); line-height: var(--space-m); - @nest .is-initial.is-password-empty & { + .is-initial.is-password-empty & { margin: 0; line-height: 0; } @@ -163,7 +163,7 @@ color: var(--progress-bar-description-color); font-size: var(--progress-bar-description-font-size); - @nest .is-confirm-empty & { + .is-confirm-empty & { visibility: hidden; } } diff --git a/core/themes/claro/css/components/form--select.pcss.css b/core/themes/claro/css/components/form--select.pcss.css index 6b4c992955c..ce3fd572a13 100644 --- a/core/themes/claro/css/components/form--select.pcss.css +++ b/core/themes/claro/css/components/form--select.pcss.css @@ -20,12 +20,13 @@ background-image: url(../../images/icons/8e929c/chevron-down.svg); } - @nest [dir="rtl"] & { + [dir="rtl"] & { background-position: 0 50%; } - @nest .no-touchevents & { - &.form-element--extrasmall, &[name$="][_weight]"] { + .no-touchevents & { + &.form-element--extrasmall, + &[name$="][_weight]"] { padding-inline-end: calc(1.5rem - var(--input-border-size)); background-size: 1.75rem 0.4375rem; /* w: 14px + (2 * 7px), h: 7px */ } diff --git a/core/themes/claro/css/components/messages.pcss.css b/core/themes/claro/css/components/messages.pcss.css index 69d75c63528..1bff37f060b 100644 --- a/core/themes/claro/css/components/messages.pcss.css +++ b/core/themes/claro/css/components/messages.pcss.css @@ -56,7 +56,7 @@ margin: 0; } - @nest [dir="rtl"] & { + [dir="rtl"] & { border-right-width: var(--messages-border-width); border-left-width: 0; } @@ -97,7 +97,7 @@ align-items: center; margin-block-end: var(--space-m); - @nest [dir="rtl"] & { + [dir="rtl"] & { background-position: center right; } } diff --git a/core/themes/claro/css/components/page-title.pcss.css b/core/themes/claro/css/components/page-title.pcss.css index 0de14eb2276..11364373446 100644 --- a/core/themes/claro/css/components/page-title.pcss.css +++ b/core/themes/claro/css/components/page-title.pcss.css @@ -17,7 +17,7 @@ font-size: var(--font-size-h1); -webkit-font-smoothing: antialiased; - @nest .region-header > & { + .region-header > & { /** * In this case page title is not rendered as a block ¯\_(ツ)_/¯. * diff --git a/core/themes/claro/css/components/shortcut.pcss.css b/core/themes/claro/css/components/shortcut.pcss.css index 72e157db6fc..87644ecea2f 100644 --- a/core/themes/claro/css/components/shortcut.pcss.css +++ b/core/themes/claro/css/components/shortcut.pcss.css @@ -48,19 +48,21 @@ vertical-align: -0.0625rem; background: transparent url(../../images/shortcut/favstar.svg) left top / calc(var(--shortcut-icon-size) * 4) var(--shortcut-icon-size) no-repeat; - @nest .shortcut-action--add:hover &, .shortcut-action--add:focus & { + .shortcut-action--add:hover &, + .shortcut-action--add:focus & { background-position: calc(-1 * var(--shortcut-icon-size)) top; } - @nest .shortcut-action--remove & { + .shortcut-action--remove & { background-position: calc(-2 * var(--shortcut-icon-size)) top; } - @nest .shortcut-action--remove:focus &, .shortcut-action--remove:hover & { + .shortcut-action--remove:focus &, + .shortcut-action--remove:hover & { background-position: calc(-3 * var(--shortcut-icon-size)) top; } - @nest [dir="rtl"] & { + [dir="rtl"] & { background-image: url(../../images/shortcut/favstar-rtl.svg); } } diff --git a/core/themes/claro/css/components/system-admin--admin-list.pcss.css b/core/themes/claro/css/components/system-admin--admin-list.pcss.css index 92f32d49b94..2db336bfc36 100644 --- a/core/themes/claro/css/components/system-admin--admin-list.pcss.css +++ b/core/themes/claro/css/components/system-admin--admin-list.pcss.css @@ -51,7 +51,7 @@ background: transparent no-repeat 50% 50%; background-image: url(../../images/icons/003ecc/arrow-right.svg); - @nest [dir="rtl"] & { + [dir="rtl"] & { transform: scaleX(-1); } diff --git a/core/themes/claro/css/components/system-status-counter.css b/core/themes/claro/css/components/system-status-counter.css index a8d21be0cf0..764a9176a6f 100644 --- a/core/themes/claro/css/components/system-status-counter.css +++ b/core/themes/claro/css/components/system-status-counter.css @@ -35,6 +35,10 @@ background-position: right center; background-size: 2.5rem; } +[dir="rtl"] .system-status-counter__status-icon::before { + border-inline-end: 1px solid #e6e4df; + border-inline-start: 0; +} @media (forced-colors: active) { .system-status-counter__status-icon::before { background-color: canvastext; @@ -44,9 +48,6 @@ mask-size: 2.5rem; } } -[dir="rtl"] .system-status-counter__status-icon::before { - background-position: left center; -} .system-status-counter__status-icon--error::before { background-image: var(--system-status-counter-status-icon-error); } diff --git a/core/themes/claro/css/components/system-status-counter.pcss.css b/core/themes/claro/css/components/system-status-counter.pcss.css index a5b139eabe4..f61ca3a374a 100644 --- a/core/themes/claro/css/components/system-status-counter.pcss.css +++ b/core/themes/claro/css/components/system-status-counter.pcss.css @@ -30,6 +30,11 @@ background-position: right center; background-size: 40px; + [dir="rtl"] & { + border-inline-end: 1px solid #e6e4df; + border-inline-start: 0; + } + @media (forced-colors: active) { background-color: canvastext; background-image: none; @@ -37,10 +42,6 @@ mask-position: right center; mask-size: 40px; } - - @nest [dir="rtl"] & { - background-position: left center; - } } } diff --git a/core/themes/claro/css/components/tabledrag.pcss.css b/core/themes/claro/css/components/tabledrag.pcss.css index 2484ef2a4c3..4a90d40a10b 100644 --- a/core/themes/claro/css/components/tabledrag.pcss.css +++ b/core/themes/claro/css/components/tabledrag.pcss.css @@ -53,16 +53,16 @@ body.drag-y { .tabledrag-changed { /* Don't display the abbreviation of 'add-new' table rows. */ - @nest .add-new & { + .add-new & { display: none; } - @nest .draggable & { + .draggable & { position: relative; inset-inline-start: calc(var(--space-xs) * -1); } - @nest .tabledrag-cell--only-drag & { + .tabledrag-cell--only-drag & { width: var(--space-l); min-width: var(--space-l); } @@ -189,7 +189,7 @@ body.drag-y { text-align: end; /* Hide nested weight toggles as they are redundant. */ - @nest .draggable-table & { + .draggable-table & { display: none; } } @@ -251,7 +251,7 @@ body.drag-y { background: none !important; line-height: 0; - @nest .tabledrag-cell-content & { + .tabledrag-cell-content & { /* Fixes Safari bug (16.1 at least) where table rows are overly large when using indentation (e.g. re-ordering menu items. */ display: inline-flex; @@ -261,7 +261,7 @@ body.drag-y { height: 100%; } - @nest [dir="rtl"] & { + [dir="rtl"] & { float: right; } } diff --git a/core/themes/claro/css/components/tabs.css b/core/themes/claro/css/components/tabs.css index 523d3c60408..438e796d775 100644 --- a/core/themes/claro/css/components/tabs.css +++ b/core/themes/claro/css/components/tabs.css @@ -172,6 +172,10 @@ box-shadow: none; } +.position-container { + position: relative; +} + @media screen and (min-width: 48em) { .tabs-wrapper { display: flex; diff --git a/core/themes/claro/css/components/tabs.pcss.css b/core/themes/claro/css/components/tabs.pcss.css index 6e89d174412..25a46ffec5f 100644 --- a/core/themes/claro/css/components/tabs.pcss.css +++ b/core/themes/claro/css/components/tabs.pcss.css @@ -152,6 +152,10 @@ } } +.position-container { + position: relative; +} + @media screen and (min-width: 48em) { .tabs-wrapper { display: flex; diff --git a/core/themes/claro/css/components/vertical-tabs.pcss.css b/core/themes/claro/css/components/vertical-tabs.pcss.css index ef09e440db8..bdac67e2369 100644 --- a/core/themes/claro/css/components/vertical-tabs.pcss.css +++ b/core/themes/claro/css/components/vertical-tabs.pcss.css @@ -27,7 +27,7 @@ list-style: none; color: var(--color-text); - @nest [dir="rtl"] & { + [dir="rtl"] & { float: right; } } @@ -224,7 +224,7 @@ margin-inline-start: var(--vertical-tabs-menu-width); border-top-left-radius: 0; - @nest [dir="rtl"] & { + [dir="rtl"] & { border-top-left-radius: var(--vertical-tabs-border-radius); } } diff --git a/core/themes/claro/css/components/views_ui.admin.pcss.css b/core/themes/claro/css/components/views_ui.admin.pcss.css index b4f1dd72e0b..38ead2a8e6f 100644 --- a/core/themes/claro/css/components/views_ui.admin.pcss.css +++ b/core/themes/claro/css/components/views_ui.admin.pcss.css @@ -50,7 +50,7 @@ margin-inline-start: 0; } - @nest [dir="rtl"] & { + [dir="rtl"] & { & > * { float: right; } diff --git a/core/themes/claro/images/icons/buttonText/ex.svg b/core/themes/claro/images/icons/buttonText/ex.svg deleted file mode 100644 index 635ac1c6b38..00000000000 --- a/core/themes/claro/images/icons/buttonText/ex.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11 1.318l-10 10M11 11.318l-10-10" stroke="buttonText" stroke-width="1.5"/></svg> diff --git a/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml b/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml deleted file mode 100644 index 343b8d3256a..00000000000 --- a/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml +++ /dev/null @@ -1,20 +0,0 @@ -langcode: en -status: true -dependencies: - module: - - node - theme: - - olivero -id: olivero_syndicate -theme: olivero -region: social -weight: 0 -provider: null -plugin: node_syndicate_block -settings: - id: node_syndicate_block - label: 'RSS feed' - label_display: '0' - provider: node - block_count: 10 -visibility: { } diff --git a/core/themes/olivero/olivero.libraries.yml b/core/themes/olivero/olivero.libraries.yml index c699ebc72c8..137a4296eb6 100644 --- a/core/themes/olivero/olivero.libraries.yml +++ b/core/themes/olivero/olivero.libraries.yml @@ -45,7 +45,6 @@ global-styling: css/components/site-header.css: {} css/components/skip-link.css: {} css/components/pager.css: {} - css/components/table.css: {} css/components/text-content.css: {} css/components/wide-content.css: {} @@ -291,3 +290,18 @@ tags: css: theme: css/components/tags.css: {} + +olivero.table: + version: VERSION + css: + component: + css/components/table.css: {} + moved_files: + olivero/global-styling: + deprecation_version: 11.2.0 + removed_version: 12.0.0 + deprecation_link: https://www.drupal.org/node/3517675 + css: + component: + css/components/table.css: + base: css/components/table.css diff --git a/core/themes/olivero/olivero.theme b/core/themes/olivero/olivero.theme index d10ee7d155c..b2f3bff2684 100644 --- a/core/themes/olivero/olivero.theme +++ b/core/themes/olivero/olivero.theme @@ -617,6 +617,15 @@ function olivero_preprocess_table(&$variables): void { } } } + + $variables['#attached']['library'][] = 'olivero/olivero.table'; +} + +/** + * Implements hook_preprocess_HOOK() for views-view-table templates. + */ +function olivero_preprocess_views_view_table(&$variables): void { + $variables['#attached']['library'][] = 'olivero/olivero.table'; } /** diff --git a/core/themes/stable9/stable9.info.yml b/core/themes/stable9/stable9.info.yml index 71dc76f30b6..c7c23976444 100644 --- a/core/themes/stable9/stable9.info.yml +++ b/core/themes/stable9/stable9.info.yml @@ -240,7 +240,6 @@ libraries-override: css/components/hidden.module.css: css/system/components/hidden.module.css css/components/item-list.module.css: css/system/components/item-list.module.css css/components/js.module.css: css/system/components/js.module.css - css/components/position-container.module.css: css/system/components/position-container.module.css css/components/reset-appearance.module.css: css/system/components/reset-appearance.module.css system/admin: css: |