From 753de5441d41452603916ed55bb89632fcb654c9 Mon Sep 17 00:00:00 2001 From: Lennart Van Vaerenbergh Date: Fri, 18 Sep 2026 17:18:52 +0200 Subject: [PATCH] Fix PHPUnit configuration discovery for Drupal QA --- CHANGELOG.md | 18 ++++++ composer.json | 2 +- configs/phpunit-extension-11.xml | 18 ++++-- configs/phpunit-extension.xml | 18 ++++-- configs/phpunit-site-11.xml | 27 +++++++-- configs/phpunit-site.xml | 27 +++++++-- src/GrumPHP/ConfigFileMerger.php | 13 ++++- src/GrumPHP/PhpunitVersionResolver.php | 43 ++++++++++++++ src/PHPUnit/Extension/bootstrap.php | 25 +++++++- .../BootstrapCompatibilityTest.php | 58 +++++++++++++++++++ .../src/Unit/GrumPHP/ConfigFileMergerTest.php | 33 ++++++++++- .../GrumPHP/PhpunitVersionResolverTest.php | 26 +++++++++ 12 files changed, 274 insertions(+), 34 deletions(-) create mode 100644 src/GrumPHP/PhpunitVersionResolver.php create mode 100644 tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d352b74..daf0f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All Notable changes to the **Quality Assurance - Drupal** package. +## [5.0.3] + +### Fixed + +- Select the PHPUnit 11 or 12 configuration from the consumer project's + Composer metadata instead of the PHPUnit version bundled in GrumPHP. +- Preserve symlinked extension discovery while preventing recursive traversal + through directory-link cycles. +- Bound PHPUnit source exclusions to extension source paths so coverage + mapping no longer traverses Composer dependencies or follows recursive + repository links. + +### Changed + +- Require QA PHP 3.1.1 or later so generated PHPStan configurations include + the GrumPHP API stubs required by the shared event listener. + ## [5.0.2] ### Fixed @@ -193,6 +210,7 @@ Older releases (2.x.x) see [CHANGELOG-2.x.md](CHANGELOG-1.x.md). Older releases (1.x.x) see [CHANGELOG-1.x.md](CHANGELOG-1.x.md). +[5.0.3]: https://github.com/district09/php_package_qa-drupal/compare/5.0.2...5.0.3 [5.0.2]: https://github.com/district09/php_package_qa-drupal/compare/5.0.1...5.0.2 [5.0.1]: https://github.com/district09/php_package_qa-drupal/compare/5.0.0...5.0.1 [5.0.0]: https://github.com/district09/php_package_qa-drupal/compare/4.1.4...5.0.0 diff --git a/composer.json b/composer.json index 7165d45..481e89d 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "php": "^8.3", "behat/mink": "^1.13", "behat/mink-browserkit-driver": "^2.3", - "district09/qa-php": "^3.0.3", + "district09/qa-php": "^3.1.1", "drupal/coder": "^9.0", "drupal/core": "^11.4 || ^12.0", "friendsoftwig/twigcs": "^6.5", diff --git a/configs/phpunit-extension-11.xml b/configs/phpunit-extension-11.xml index 07903fc..c93c8cf 100644 --- a/configs/phpunit-extension-11.xml +++ b/configs/phpunit-extension-11.xml @@ -44,12 +44,18 @@ ./modules/**/src - ./ - ./ - ./ - ./ - ./ - ./ + ./src + ./src + ./src + ./src + ./src + ./src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src diff --git a/configs/phpunit-extension.xml b/configs/phpunit-extension.xml index ceb747a..b61f244 100644 --- a/configs/phpunit-extension.xml +++ b/configs/phpunit-extension.xml @@ -44,12 +44,18 @@ ./modules/**/src - ./ - ./ - ./ - ./ - ./ - ./ + ./src + ./src + ./src + ./src + ./src + ./src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src + ./modules/**/src diff --git a/configs/phpunit-site-11.xml b/configs/phpunit-site-11.xml index 2d705b2..2623845 100644 --- a/configs/phpunit-site-11.xml +++ b/configs/phpunit-site-11.xml @@ -67,12 +67,27 @@ web/themes/custom - ./ - ./ - ./ - ./ - ./ - ./ + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom/**/tests + web/profiles/custom/**/tests + web/themes/custom/**/tests web/themes/custom/*/source diff --git a/configs/phpunit-site.xml b/configs/phpunit-site.xml index 50c8a52..bf84f8f 100644 --- a/configs/phpunit-site.xml +++ b/configs/phpunit-site.xml @@ -67,12 +67,27 @@ web/themes/custom - ./ - ./ - ./ - ./ - ./ - ./ + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom + web/profiles/custom + web/themes/custom + web/modules/custom/**/tests + web/profiles/custom/**/tests + web/themes/custom/**/tests web/themes/custom/*/source diff --git a/src/GrumPHP/ConfigFileMerger.php b/src/GrumPHP/ConfigFileMerger.php index 84658d1..633ec32 100644 --- a/src/GrumPHP/ConfigFileMerger.php +++ b/src/GrumPHP/ConfigFileMerger.php @@ -11,7 +11,6 @@ use GrumPHP\Task\Phpunit; use GrumPHP\Task\TaskInterface; use Nette\Neon\Neon; -use PHPUnit\Runner\Version; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; @@ -35,6 +34,14 @@ final class ConfigFileMerger private const FILETYPE_YAML = 'yaml'; private const FILETYPE_NEON = 'neon'; + /** + * PHPUnit configuration suffixes by supported runner major. + */ + private const PHPUNIT_TEMPLATE_SUFFIXES = [ + 11 => '-11', + 12 => '', + ]; + /** * Mapping of task types and their configuration files. * @@ -179,8 +186,8 @@ private function getConfigFileCandidates( $packageTypeFilename = $taskInfo['filename'] . '-' . $type; // PHPUnit configuration schemas are specific to the runner major. - if ($taskInfo['filename'] === 'phpunit' && Version::majorVersionNumber() === 11) { - $packageTypeFilename .= '-11'; + if ($taskInfo['filename'] === 'phpunit') { + $packageTypeFilename .= self::PHPUNIT_TEMPLATE_SUFFIXES[PhpunitVersionResolver::installedMajorVersion()]; } return [ diff --git a/src/GrumPHP/PhpunitVersionResolver.php b/src/GrumPHP/PhpunitVersionResolver.php new file mode 100644 index 0000000..9ca14d0 --- /dev/null +++ b/src/GrumPHP/PhpunitVersionResolver.php @@ -0,0 +1,43 @@ +\d+)/', $version, $matches)) { + throw new \LogicException(sprintf( + 'Unable to determine the PHPUnit major version from "%s".', + $version, + )); + } + + $major = (int) $matches['major']; + if (!in_array($major, [11, 12], TRUE)) { + throw new \LogicException(sprintf( + 'Unsupported PHPUnit major version %d. Supported versions are 11 and 12.', + $major, + )); + } + + return $major; + } + +} diff --git a/src/PHPUnit/Extension/bootstrap.php b/src/PHPUnit/Extension/bootstrap.php index 5299b79..03e63c8 100644 --- a/src/PHPUnit/Extension/bootstrap.php +++ b/src/PHPUnit/Extension/bootstrap.php @@ -71,14 +71,33 @@ function drupal_phpunit_core_dir(): string { */ function drupal_phpunit_find_extension_directories(string $scanDirectory): array { $extensions = []; + $visitedDirectories = []; + $realScanDirectory = realpath($scanDirectory); + if ($realScanDirectory !== FALSE) { + $visitedDirectories[$realScanDirectory] = TRUE; + } + $iterator = new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $scanDirectory, RecursiveDirectoryIterator::FOLLOW_SYMLINKS | RecursiveDirectoryIterator::SKIP_DOTS ), - static function (SplFileInfo $file): bool { - return !$file->isDir() - || !in_array($file->getFilename(), ['.git', 'node_modules', 'vendor'], TRUE); + static function (SplFileInfo $file) use (&$visitedDirectories): bool { + if (!$file->isDir()) { + return TRUE; + } + + if (in_array($file->getFilename(), ['.git', 'node_modules', 'vendor'], TRUE)) { + return FALSE; + } + + $realPath = $file->getRealPath(); + if ($realPath === FALSE || isset($visitedDirectories[$realPath])) { + return FALSE; + } + + $visitedDirectories[$realPath] = TRUE; + return TRUE; } ); $directories = new RecursiveIteratorIterator( diff --git a/tests/src/Compatibility/BootstrapCompatibilityTest.php b/tests/src/Compatibility/BootstrapCompatibilityTest.php index 94bdead..1f99d0f 100644 --- a/tests/src/Compatibility/BootstrapCompatibilityTest.php +++ b/tests/src/Compatibility/BootstrapCompatibilityTest.php @@ -20,4 +20,62 @@ public function testDrupalTestNamespacesAreAvailable(): void { self::assertTrue(class_exists('Behat\\Mink\\Element\\DocumentElement')); } + /** + * Tests that extension discovery follows symlinked extension directories. + */ + public function testExtensionDiscoveryFollowsDirectorySymlinks(): void { + $directory = sys_get_temp_dir() . '/qa-drupal-bootstrap-' . bin2hex(random_bytes(8)); + $extensionDirectory = $directory . '-extension'; + mkdir($directory, 0777, TRUE); + mkdir($extensionDirectory, 0777, TRUE); + file_put_contents($extensionDirectory . '/example.info.yml', "name: Example\ntype: module\n"); + + if (!symlink($extensionDirectory, $directory . '/example')) { + unlink($extensionDirectory . '/example.info.yml'); + rmdir($extensionDirectory); + rmdir($directory); + self::markTestSkipped('Directory symlinks are not supported.'); + } + + try { + $extensions = drupal_phpunit_find_extension_directories($directory); + self::assertSame(realpath($extensionDirectory), $extensions['example']); + } + finally { + unlink($directory . '/example'); + unlink($extensionDirectory . '/example.info.yml'); + rmdir($extensionDirectory); + rmdir($directory); + } + } + + /** + * Tests that extension discovery skips directory symlink cycles. + */ + public function testExtensionDiscoverySkipsDirectorySymlinkCycles(): void { + $directory = sys_get_temp_dir() . '/qa-drupal-bootstrap-' . bin2hex(random_bytes(8)); + $extensionDirectory = $directory . '/example'; + mkdir($extensionDirectory, 0777, TRUE); + file_put_contents($extensionDirectory . '/example.info.yml', "name: Example\ntype: module\n"); + + if (!symlink($directory, $extensionDirectory . '/loop')) { + unlink($extensionDirectory . '/example.info.yml'); + rmdir($extensionDirectory); + rmdir($directory); + self::markTestSkipped('Directory symlinks are not supported.'); + } + + try { + $extensions = drupal_phpunit_find_extension_directories($directory); + self::assertSame(realpath($extensionDirectory), $extensions['example']); + self::assertCount(1, $extensions); + } + finally { + unlink($extensionDirectory . '/loop'); + unlink($extensionDirectory . '/example.info.yml'); + rmdir($extensionDirectory); + rmdir($directory); + } + } + } diff --git a/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php b/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php index 8d41d82..d9a8321 100644 --- a/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php +++ b/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php @@ -5,11 +5,11 @@ namespace Digipolisgent\QA\Drupal\Tests\Unit\GrumPHP; use Digipolisgent\QA\Drupal\GrumPHP\ConfigFileMerger; +use Digipolisgent\QA\Drupal\GrumPHP\PhpunitVersionResolver; use GrumPHP\Task\PhpStan; use GrumPHP\Task\Phpunit; use Nette\Neon\Neon; use PHPUnit\Framework\TestCase; -use PHPUnit\Runner\Version; /** * Tests merging project-specific GrumPHP task configuration. @@ -103,7 +103,7 @@ public function testPhpunitConfigurationUsesCurrentRunnerSchema(): void { (new ConfigFileMerger())->mergeTaskConfig($task, TRUE); $configuration = (string) file_get_contents('phpunit.qa-drupal.xml'); - $schemaVersion = Version::majorVersionNumber() === 11 ? '11.5' : '12.5'; + $schemaVersion = PhpunitVersionResolver::installedMajorVersion() === 11 ? '11.5' : '12.5'; self::assertStringContainsString( "https://schema.phpunit.de/$schemaVersion/phpunit.xsd", $configuration @@ -111,7 +111,17 @@ public function testPhpunitConfigurationUsesCurrentRunnerSchema(): void { } /** - * Tests that extension test discovery is limited to project code. + * Tests that every supported PHPUnit major has a configuration template. + */ + public function testPhpunitTemplateSuffixesCoverSupportedRunners(): void { + $suffixes = (new \ReflectionClass(ConfigFileMerger::class)) + ->getConstant('PHPUNIT_TEMPLATE_SUFFIXES'); + + self::assertSame([11 => '-11', 12 => ''], $suffixes); + } + + /** + * Tests that extension coverage stays within project source paths. */ public function testExtensionPhpunitConfigurationExcludesVendorPaths(): void { $task = (new \ReflectionClass(Phpunit::class))->newInstanceWithoutConstructor(); @@ -123,6 +133,23 @@ public function testExtensionPhpunitConfigurationExcludesVendorPaths(): void { self::assertStringContainsString('tests/src', $configuration); self::assertStringNotContainsString('tests/src/Kernel', $configuration); self::assertStringNotContainsString('./**/src', $configuration); + self::assertStringNotContainsString('>./', $configuration); + self::assertStringContainsString('./src', $configuration); + self::assertStringContainsString('./modules/**/src', $configuration); + } + + /** + * Tests that site source exclusions remain inside custom extension paths. + */ + public function testSitePhpunitConfigurationExcludesVendorPaths(): void { + $task = (new \ReflectionClass(Phpunit::class))->newInstanceWithoutConstructor(); + (new ConfigFileMerger())->mergeTaskConfig($task, FALSE); + + $configuration = (string) file_get_contents('phpunit.qa-drupal.xml'); + self::assertStringContainsString('web/modules/custom/**/tests', $configuration); + self::assertStringNotContainsString('>./', $configuration); + self::assertStringContainsString('web/themes/custom', $configuration); + self::assertStringContainsString('web/profiles/custom', $configuration); } /** diff --git a/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php b/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php new file mode 100644 index 0000000..5c1fff4 --- /dev/null +++ b/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php @@ -0,0 +1,26 @@ +