From de80ce2d33cee89dcf9cea52a84bc7ee35085890 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 10:36:03 +0200 Subject: [PATCH 01/24] GH-119: Add module-scoped phpunit GrumPHP task - introduced PhpUnitModules GrumPHP task that discovers affected custom modules from the current context - wired PhpUnitModules task to run phpunit via ddev only for changed custom modules - added task configuration defaults (paths, ignore patterns, extensions) to tasks.yml Refs: src/Task/PhpUnitModules/PhpUnitModulesTask.php, src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php, src/Task/PhpUnitModules/services.yaml, src/Task/tasks.yml --- .../PhpUnitModulesExtensionLoader.php | 13 ++++ .../PhpUnitModules/PhpUnitModulesTask.php | 70 +++++++++++++++++++ src/Task/PhpUnitModules/services.yaml | 9 +++ src/Task/tasks.yml | 18 +++++ 4 files changed, 110 insertions(+) create mode 100644 src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php create mode 100644 src/Task/PhpUnitModules/PhpUnitModulesTask.php create mode 100644 src/Task/PhpUnitModules/services.yaml diff --git a/src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php b/src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php new file mode 100644 index 0000000..d0dcdfc --- /dev/null +++ b/src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php @@ -0,0 +1,13 @@ +getPathsOrResult($context, $this->getConfig()->getOptions(), $this); + if ($paths instanceof TaskResultInterface) { + return $paths; + } + + $modules = []; + foreach ($paths as $file) { + $path = (string) $file; + + // Only consider custom modules. Contrib modules are intentionally ignored. + if (!str_starts_with($path, 'web/modules/custom/')) { + continue; + } + + if (preg_match('#^(web/modules/custom/[^/]+)#', $path, $matches)) { + $modules[$matches[1]] = $matches[1]; + } + } + + // No affected modules -> nothing to run. + if (!$modules) { + return TaskResult::createSkipped($this, $context); + } + + $process = $this->processBuilder->buildProcess($this->buildArguments($modules)); + $process->run(); + + return $this->getTaskResult($process, $context); + } + + /** + * {@inheritdoc} + */ + public function buildArguments(iterable $modules): ProcessArgumentsCollection { + // Use the local DDEV phpunit wrapper. + $arguments = $this->processBuilder->createArgumentsForCommand('ddev'); + $arguments->add('phpunit'); + + foreach ($modules as $modulePath) { + $arguments->add($modulePath); + } + + return $arguments; + } + +} + diff --git a/src/Task/PhpUnitModules/services.yaml b/src/Task/PhpUnitModules/services.yaml new file mode 100644 index 0000000..7816884 --- /dev/null +++ b/src/Task/PhpUnitModules/services.yaml @@ -0,0 +1,9 @@ +services: + Wunderio\GrumPHP\Task\PhpUnitModules\PhpUnitModulesTask: + class: Wunderio\GrumPHP\Task\PhpUnitModules\PhpUnitModulesTask + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - { name: grumphp.task, task: php_unit_modules } + diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index e750c62..74c38b7 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -281,3 +281,21 @@ Wunderio\GrumPHP\Task\Psalm\PsalmTask: show_info: defaults: false allowed_types: ['bool'] + +Wunderio\GrumPHP\Task\PhpUnitModules\PhpUnitModulesTask: + is_file_specific: true + options: + ignore_patterns: + defaults: + - '/vendor/' + - '/node_modules/' + - '/core/' + - '/libraries/' + - '/contrib/' + allowed_types: ['array'] + extensions: + defaults: ['php', 'inc', 'module', 'install', 'theme'] + allowed_types: ['array'] + run_on: + defaults: ['web/modules/custom'] + allowed_types: ['array'] From 92e3d14486c4877d9356a0fae3a9ee8c1ebbee60 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 10:45:39 +0200 Subject: [PATCH 02/24] GH-119: Add phpunit_drupal_modules GrumPHP task - renamed and refined the custom phpunit task to PhpUnitDrupalModules to clearly target Drupal custom modules - registered the new PhpUnitDrupalModules task and extension in default code-quality grumphp configuration - wired PhpUnitDrupalModules task into task configuration with sensible defaults for custom module paths and ignore patterns Refs: config/grumphp.yml, src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php, src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php, src/Task/PhpUnitDrupalModules/services.yaml, src/Task/tasks.yml --- config/grumphp.yml | 2 ++ .../PhpUnitDrupalModulesExtensionLoader.php | 13 +++++++++++++ .../PhpUnitDrupalModulesTask.php} | 10 +++++----- src/Task/PhpUnitDrupalModules/services.yaml | 9 +++++++++ .../PhpUnitModulesExtensionLoader.php | 13 ------------- src/Task/PhpUnitModules/services.yaml | 9 --------- src/Task/tasks.yml | 2 +- 7 files changed, 30 insertions(+), 28 deletions(-) create mode 100644 src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php rename src/Task/{PhpUnitModules/PhpUnitModulesTask.php => PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php} (83%) create mode 100644 src/Task/PhpUnitDrupalModules/services.yaml delete mode 100644 src/Task/PhpUnitModules/PhpUnitModulesExtensionLoader.php delete mode 100644 src/Task/PhpUnitModules/services.yaml diff --git a/config/grumphp.yml b/config/grumphp.yml index 4d3f96e..09331b7 100644 --- a/config/grumphp.yml +++ b/config/grumphp.yml @@ -13,6 +13,7 @@ grumphp: yaml_lint: ~ json_lint: ~ psalm: ~ + phpunit_drupal_modules: ~ extensions: - Wunderio\GrumPHP\Task\PhpCompatibility\PhpCompatibilityExtensionLoader - Wunderio\GrumPHP\Task\PhpCheckSyntax\PhpCheckSyntaxExtensionLoader @@ -23,3 +24,4 @@ grumphp: - Wunderio\GrumPHP\Task\YamlLint\YamlLintExtensionLoader - Wunderio\GrumPHP\Task\JsonLint\JsonLintExtensionLoader - Wunderio\GrumPHP\Task\Psalm\PsalmExtensionLoader + - Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesExtensionLoader diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php new file mode 100644 index 0000000..9b04c49 --- /dev/null +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php @@ -0,0 +1,13 @@ + Date: Mon, 16 Mar 2026 11:32:58 +0200 Subject: [PATCH 03/24] 119: Optimize Drupal module phpunit task - Ensure PhpUnitDrupalModules task cleanly skips when no matching module paths are found - Run phpunit directly instead of via ddev wrapper when executing affected module tests Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 0bc931b..c18929b 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -22,6 +22,7 @@ class PhpUnitDrupalModulesTask extends AbstractMultiPathProcessingTask { */ public function run(ContextInterface $context): TaskResultInterface { $paths = $this->getPathsOrResult($context, $this->getConfig()->getOptions(), $this); + if ($paths instanceof TaskResultInterface) { return $paths; } @@ -29,7 +30,6 @@ public function run(ContextInterface $context): TaskResultInterface { $modules = []; foreach ($paths as $file) { $path = (string) $file; - // Only consider custom Drupal modules. Contrib modules are intentionally ignored. if (!str_starts_with($path, 'web/modules/custom/')) { continue; @@ -56,8 +56,7 @@ public function run(ContextInterface $context): TaskResultInterface { */ public function buildArguments(iterable $modules): ProcessArgumentsCollection { // Use the local DDEV phpunit wrapper. - $arguments = $this->processBuilder->createArgumentsForCommand('ddev'); - $arguments->add('phpunit'); + $arguments = $this->processBuilder->createArgumentsForCommand('phpunit'); foreach ($modules as $modulePath) { $arguments->add($modulePath); From 81f1c2443d68c5a72d634634588f48b7fdecdede Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 12:06:53 +0200 Subject: [PATCH 04/24] #119: Add configurable phpunit config file for Drupal modules task - Allow PhpUnitDrupalModules task to accept a config_file option matching the core phpunit task - Pass the configured phpunit XML file via -c when running tests for affected custom modules Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php, src/Task/tasks.yml --- .../PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 9 ++++++++- src/Task/tasks.yml | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index c18929b..a1cfd24 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -55,9 +55,16 @@ public function run(ContextInterface $context): TaskResultInterface { * {@inheritdoc} */ public function buildArguments(iterable $modules): ProcessArgumentsCollection { - // Use the local DDEV phpunit wrapper. + $config = $this->getConfig()->getOptions(); + $arguments = $this->processBuilder->createArgumentsForCommand('phpunit'); + if (!empty($config['config_file'])) { + // Mirror GrumPHP's core phpunit task: allow passing a custom config file. + $arguments->add('-c'); + $arguments->add($config['config_file']); + } + foreach ($modules as $modulePath) { $arguments->add($modulePath); } diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index 3faf581..ac33cb5 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -299,3 +299,6 @@ Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: run_on: defaults: ['web/modules/custom'] allowed_types: ['array'] + config_file: + defaults: ~ + allowed_types: ['string', 'null'] From 3866dde4e859c9b40ba16ea18693e85a195e5887 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 12:24:14 +0200 Subject: [PATCH 05/24] #119: Add testsuite option to Drupal modules phpunit task - Allow PhpUnitDrupalModules task to accept a configurable testsuite name passed through to phpunit - Extend task configuration schema to expose the new testsuite option Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php, src/Task/tasks.yml --- src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 5 +++++ src/Task/tasks.yml | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index a1cfd24..638f77b 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -65,6 +65,11 @@ public function buildArguments(iterable $modules): ProcessArgumentsCollection { $arguments->add($config['config_file']); } + if (!empty($config['testsuite'])) { + $arguments->add('--testsuite'); + $arguments->add($config['testsuite']); + } + foreach ($modules as $modulePath) { $arguments->add($modulePath); } diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index ac33cb5..d118b5e 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -302,3 +302,6 @@ Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: config_file: defaults: ~ allowed_types: ['string', 'null'] + testsuite: + defaults: ~ + allowed_types: ['string', 'null'] From e993a8633a7a3f8adf72f274a23241089829567d Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 12:49:43 +0200 Subject: [PATCH 06/24] #119: Print affected modules before running phpunit - Output the list of affected custom Drupal modules before executing the phpunit_drupal_modules task for better commit-time visibility Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 638f77b..a092a09 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -45,6 +45,16 @@ public function run(ContextInterface $context): TaskResultInterface { return TaskResult::createSkipped($this, $context); } + // Provide a short hint about which modules will be tested. + // This mirrors GrumPHP's own task output style without being too noisy. + fwrite( + STDOUT, + sprintf( + "phpunit_drupal_modules: running tests for modules: %s\n", + implode(', ', array_values($modules)) + ) + ); + $process = $this->processBuilder->buildProcess($this->buildArguments($modules)); $process->run(); From 2a5f9ebf13a3b620fb27ce83282c01c922da06ec Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 12:53:22 +0200 Subject: [PATCH 07/24] #119: Clarify and tighten Drupal modules phpunit task output - Only run phpunit for custom modules that actually contain a tests directory - Print affected modules one per line before executing the phpunit_drupal_modules task for clearer feedback Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index a092a09..8ce8d80 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -40,8 +40,16 @@ public function run(ContextInterface $context): TaskResultInterface { } } - // No affected modules -> nothing to run. - if (!$modules) { + // Further restrict to modules that actually have a tests directory. + $modulesWithTests = []; + foreach ($modules as $modulePath) { + if (is_dir($modulePath . '/tests')) { + $modulesWithTests[] = $modulePath; + } + } + + // No affected modules with tests -> nothing to run. + if (!$modulesWithTests) { return TaskResult::createSkipped($this, $context); } @@ -49,13 +57,14 @@ public function run(ContextInterface $context): TaskResultInterface { // This mirrors GrumPHP's own task output style without being too noisy. fwrite( STDOUT, - sprintf( - "phpunit_drupal_modules: running tests for modules: %s\n", - implode(', ', array_values($modules)) - ) + "phpunit_drupal_modules: running tests for modules:\n" . + implode("\n", array_map(static function (string $modulePath): string { + return ' - ' . $modulePath; + }, $modulesWithTests)) . + "\n" ); - $process = $this->processBuilder->buildProcess($this->buildArguments($modules)); + $process = $this->processBuilder->buildProcess($this->buildArguments($modulesWithTests)); $process->run(); return $this->getTaskResult($process, $context); From dfa3a7e4b2583828352589a6728c77f4caf9d3f7 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 12:57:21 +0200 Subject: [PATCH 08/24] 119: Report modules without tests in phpunit task - Detect affected custom Drupal modules that lack a tests directory and list them explicitly in phpunit_drupal_modules output - Keep actual phpunit execution limited to modules that do have tests, listed one per line for clarity Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 8ce8d80..31c1489 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -48,6 +48,19 @@ public function run(ContextInterface $context): TaskResultInterface { } } + // If there are affected modules without tests, let the user know. + $modulesWithoutTests = array_values(array_diff($modules, $modulesWithTests)); + if ($modulesWithoutTests) { + fwrite( + STDOUT, + "phpunit_drupal_modules: affected modules without tests:\n" . + implode("\n", array_map(static function (string $modulePath): string { + return ' - ' . $modulePath; + }, $modulesWithoutTests)) . + "\n" + ); + } + // No affected modules with tests -> nothing to run. if (!$modulesWithTests) { return TaskResult::createSkipped($this, $context); From cca693533df52bf9e88bea17f4d28301968d193c Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Mon, 16 Mar 2026 13:01:32 +0200 Subject: [PATCH 09/24] #119: Highlight modules without tests in phpunit task - Print a clear NOTE block listing affected custom modules that have no tests configured - Keep a separate, well-spaced list of modules that will actually have phpunit tests run against them Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 31c1489..9c55800 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -53,11 +53,11 @@ public function run(ContextInterface $context): TaskResultInterface { if ($modulesWithoutTests) { fwrite( STDOUT, - "phpunit_drupal_modules: affected modules without tests:\n" . + "\nphpunit_drupal_modules: NOTE: affected modules without tests:\n" . implode("\n", array_map(static function (string $modulePath): string { return ' - ' . $modulePath; }, $modulesWithoutTests)) . - "\n" + "\n\n" ); } @@ -74,7 +74,7 @@ public function run(ContextInterface $context): TaskResultInterface { implode("\n", array_map(static function (string $modulePath): string { return ' - ' . $modulePath; }, $modulesWithTests)) . - "\n" + "\n\n" ); $process = $this->processBuilder->buildProcess($this->buildArguments($modulesWithTests)); From 51ed9d2b6dac30e439f84cdbe05681c88e5ad8a9 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:16:08 +0200 Subject: [PATCH 10/24] #119: Make phpunit_drupal_modules module roots configurable - refactor PhpUnitDrupalModulesTask to read module roots from configurable run_on option with default web/modules/custom - support multiple configurable module root directories when collecting affected modules Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 9c55800..e6e3fef 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -21,22 +21,34 @@ class PhpUnitDrupalModulesTask extends AbstractMultiPathProcessingTask { * {@inheritdoc} */ public function run(ContextInterface $context): TaskResultInterface { - $paths = $this->getPathsOrResult($context, $this->getConfig()->getOptions(), $this); + $config = $this->getConfig()->getOptions(); + $paths = $this->getPathsOrResult($context, $config, $this); if ($paths instanceof TaskResultInterface) { return $paths; } + // Determine which directory roots should be treated as Drupal module roots. + // Defaults to web/modules/custom for backward compatibility, but can be + // configured via the run_on option in tasks.yml. + $moduleRoots = $config['run_on'] ?? ['web/modules/custom']; + $moduleRoots = array_values(array_map(static function (string $root): string { + return rtrim($root, '/'); + }, $moduleRoots)); + $modules = []; foreach ($paths as $file) { $path = (string) $file; - // Only consider custom Drupal modules. Contrib modules are intentionally ignored. - if (!str_starts_with($path, 'web/modules/custom/')) { - continue; - } + foreach ($moduleRoots as $root) { + $rootWithSlash = $root . '/'; + + if (!str_starts_with($path, $rootWithSlash)) { + continue; + } - if (preg_match('#^(web/modules/custom/[^/]+)#', $path, $matches)) { - $modules[$matches[1]] = $matches[1]; + if (preg_match('#^(' . preg_quote($root, '#') . '/[^/]+)#', $path, $matches)) { + $modules[$matches[1]] = $matches[1]; + } } } From f2b754a789a4197589dc1f422807d62cd5130226 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:21:52 +0200 Subject: [PATCH 11/24] #119: Add tests for PhpUnitDrupalModulesTask arguments - add PhpUnitDrupalModulesTaskTest to verify phpunit argument building with config_file and testsuite - ensure module-only configuration builds arguments containing only affected module paths Refs: tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php --- .../PhpUnitDrupalModulesTaskTest.php | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php new file mode 100644 index 0000000..0c5de14 --- /dev/null +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -0,0 +1,145 @@ +createMock(ProcessBuilder::class); + $stub = $this->getMockBuilder(PhpUnitDrupalModulesTask::class)->setConstructorArgs([ + $processBuilder, + $this->createMock(ProcessFormatterInterface::class), + ]) + ->setMethodsExcept(['buildArguments'])->getMock(); + + $arguments = $this->createMock(ProcessArgumentsCollection::class); + $taskConfig = $this->createMock(TaskConfigInterface::class); + + $modules = [ + 'web/modules/custom/foo', + 'web/modules/custom/bar', + ]; + + $processBuilder->expects($this->once()) + ->method('createArgumentsForCommand') + ->with('phpunit') + ->willReturn($arguments); + + // Expect arguments for: + // - config file (-c ) + // - testsuite (--testsuite ) + // - each module path. + $arguments->expects($this->exactly(6)) + ->method('add') + ->withConsecutive( + ['-c'], + ['phpunit.ddev.xml'], + ['--testsuite'], + ['unit'], + ['web/modules/custom/foo'], + ['web/modules/custom/bar'] + ); + + $config = []; + foreach ($this->getConfigurations() as $name => $option) { + $config[$name] = $option['defaults']; + } + + // Explicitly configure a custom phpunit config file and testsuite to + // verify they are included in the arguments. + $config['config_file'] = 'phpunit.ddev.xml'; + $config['testsuite'] = 'unit'; + + $stub->expects($this->once()) + ->method('getConfig') + ->willReturn($taskConfig); + $taskConfig->method('getOptions')->willReturn($config); + + $actual = $stub->buildArguments($modules); + $this->assertInstanceOf(ProcessArgumentsCollection::class, $actual); + } + + /** + * Test building arguments without optional config. + * + * @covers \Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask::buildArguments + */ + public function testBuildsProcessArgumentsWithOnlyModules(): void { + $processBuilder = $this->createMock(ProcessBuilder::class); + $stub = $this->getMockBuilder(PhpUnitDrupalModulesTask::class)->setConstructorArgs([ + $processBuilder, + $this->createMock(ProcessFormatterInterface::class), + ]) + ->setMethodsExcept(['buildArguments'])->getMock(); + + $arguments = $this->createMock(ProcessArgumentsCollection::class); + $taskConfig = $this->createMock(TaskConfigInterface::class); + + $modules = [ + 'web/modules/custom/foo', + 'web/modules/custom/bar', + ]; + + $processBuilder->expects($this->once()) + ->method('createArgumentsForCommand') + ->with('phpunit') + ->willReturn($arguments); + + // Without config_file or testsuite, we should only see module paths. + $arguments->expects($this->exactly(2)) + ->method('add') + ->withConsecutive( + ['web/modules/custom/foo'], + ['web/modules/custom/bar'] + ); + + $config = []; + foreach ($this->getConfigurations() as $name => $option) { + $config[$name] = $option['defaults']; + } + + $stub->expects($this->once()) + ->method('getConfig') + ->willReturn($taskConfig); + $taskConfig->method('getOptions')->willReturn($config); + + $actual = $stub->buildArguments($modules); + $this->assertInstanceOf(ProcessArgumentsCollection::class, $actual); + } + + /** + * Gets task configurations. + * + * @return array + * Array of options. + */ + protected function getConfigurations(): array { + $tasks = Yaml::parseFile(__DIR__ . '/../../src/Task/tasks.yml'); + return $tasks[PhpUnitDrupalModulesTask::class]['options']; + } + +} + From 3238a92d638fa65db9e34c6b4b220ecf623831a4 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:23:53 +0200 Subject: [PATCH 12/24] 119: Avoid callback functions in PhpUnitDrupalModulesTask - replace array_map callbacks with explicit foreach loops when normalising module roots and formatting output - keep behaviour and messaging identical while satisfying security audit sniffs Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index e6e3fef..8fc4b54 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -32,9 +32,11 @@ public function run(ContextInterface $context): TaskResultInterface { // Defaults to web/modules/custom for backward compatibility, but can be // configured via the run_on option in tasks.yml. $moduleRoots = $config['run_on'] ?? ['web/modules/custom']; - $moduleRoots = array_values(array_map(static function (string $root): string { - return rtrim($root, '/'); - }, $moduleRoots)); + $normalisedRoots = []; + foreach ($moduleRoots as $root) { + $normalisedRoots[] = rtrim((string) $root, '/'); + } + $moduleRoots = array_values($normalisedRoots); $modules = []; foreach ($paths as $file) { @@ -63,12 +65,14 @@ public function run(ContextInterface $context): TaskResultInterface { // If there are affected modules without tests, let the user know. $modulesWithoutTests = array_values(array_diff($modules, $modulesWithTests)); if ($modulesWithoutTests) { + $lines = []; + foreach ($modulesWithoutTests as $modulePath) { + $lines[] = ' - ' . $modulePath; + } fwrite( STDOUT, "\nphpunit_drupal_modules: NOTE: affected modules without tests:\n" . - implode("\n", array_map(static function (string $modulePath): string { - return ' - ' . $modulePath; - }, $modulesWithoutTests)) . + implode("\n", $lines) . "\n\n" ); } @@ -80,12 +84,14 @@ public function run(ContextInterface $context): TaskResultInterface { // Provide a short hint about which modules will be tested. // This mirrors GrumPHP's own task output style without being too noisy. + $lines = []; + foreach ($modulesWithTests as $modulePath) { + $lines[] = ' - ' . $modulePath; + } fwrite( STDOUT, "phpunit_drupal_modules: running tests for modules:\n" . - implode("\n", array_map(static function (string $modulePath): string { - return ' - ' . $modulePath; - }, $modulesWithTests)) . + implode("\n", $lines) . "\n\n" ); From a6a243d35e43a41d35917cdbb1039080e9e9c8ee Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:32:29 +0200 Subject: [PATCH 13/24] 119: Remove trailing blank lines from files --- src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 1 - tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 8fc4b54..e3ebb8f 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -128,4 +128,3 @@ public function buildArguments(iterable $modules): ProcessArgumentsCollection { } } - diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index 0c5de14..e1240ed 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -142,4 +142,3 @@ protected function getConfigurations(): array { } } - From 6d5c8df74588b3a96811225ffd5dd824fe276c78 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:34:34 +0200 Subject: [PATCH 14/24] 119: Remove empty line from PhpUnitDrupalModulesExtensionLoader --- .../PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php index 9b04c49..3c5fc3c 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php @@ -10,4 +10,3 @@ * Registers the PhpUnitDrupalModules task in GrumPHP. */ class PhpUnitDrupalModulesExtensionLoader extends AbstractExternalExtensionLoader {} - From bc89f3165b4f77c1497e700183ceb1629d5f4273 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 11:43:07 +0200 Subject: [PATCH 15/24] #119: Refactor PhpUnitDrupalModulesTask for improved readability - Extract module root determination into `getModuleRoots()` - Move module collection from paths into `collectModulesFromPaths()` - Separate modules with and without tests into `splitModulesByTests()` - Isolate output logic for modules without tests into `printModulesWithoutTests()` - Isolate output logic for modules with tests into `printModulesWithTests()` --- .../PhpUnitDrupalModulesTask.php | 118 ++++++++++++++---- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index e3ebb8f..87332c9 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -28,16 +28,58 @@ public function run(ContextInterface $context): TaskResultInterface { return $paths; } - // Determine which directory roots should be treated as Drupal module roots. - // Defaults to web/modules/custom for backward compatibility, but can be - // configured via the run_on option in tasks.yml. + $moduleRoots = $this->getModuleRoots($config); + $modules = $this->collectModulesFromPaths($paths, $moduleRoots); + + [$modulesWithTests, $modulesWithoutTests] = $this->splitModulesByTests($modules); + + $this->printModulesWithoutTests($modulesWithoutTests); + + if (!$modulesWithTests) { + return TaskResult::createSkipped($this, $context); + } + + $this->printModulesWithTests($modulesWithTests); + + $process = $this->processBuilder->buildProcess($this->buildArguments($modulesWithTests)); + $process->run(); + + return $this->getTaskResult($process, $context); + } + + /** + * Determine which directory roots should be treated as Drupal module roots. + * + * Defaults to web/modules/custom for backward compatibility, but can be + * configured via the run_on option in tasks.yml. + * + * @param array $config + * Task configuration options. + * + * @return string[] + * Normalised module root paths. + */ + private function getModuleRoots(array $config): array { $moduleRoots = $config['run_on'] ?? ['web/modules/custom']; $normalisedRoots = []; foreach ($moduleRoots as $root) { $normalisedRoots[] = rtrim((string) $root, '/'); } - $moduleRoots = array_values($normalisedRoots); + return array_values($normalisedRoots); + } + /** + * Collect all affected modules from the changed file paths. + * + * @param iterable $paths + * Changed paths. + * @param string[] $moduleRoots + * Module root directories. + * + * @return string[] + * Module paths keyed by path for uniqueness. + */ + private function collectModulesFromPaths(iterable $paths, array $moduleRoots): array { $modules = []; foreach ($paths as $file) { $path = (string) $file; @@ -54,7 +96,19 @@ public function run(ContextInterface $context): TaskResultInterface { } } - // Further restrict to modules that actually have a tests directory. + return $modules; + } + + /** + * Split modules into ones with and without tests directories. + * + * @param string[] $modules + * All affected modules. + * + * @return array{0: string[], 1: string[]} + * First array contains modules with tests, second without. + */ + private function splitModulesByTests(array $modules): array { $modulesWithTests = []; foreach ($modules as $modulePath) { if (is_dir($modulePath . '/tests')) { @@ -62,43 +116,53 @@ public function run(ContextInterface $context): TaskResultInterface { } } - // If there are affected modules without tests, let the user know. $modulesWithoutTests = array_values(array_diff($modules, $modulesWithTests)); - if ($modulesWithoutTests) { - $lines = []; - foreach ($modulesWithoutTests as $modulePath) { - $lines[] = ' - ' . $modulePath; - } - fwrite( - STDOUT, - "\nphpunit_drupal_modules: NOTE: affected modules without tests:\n" . - implode("\n", $lines) . - "\n\n" - ); + + return [$modulesWithTests, $modulesWithoutTests]; + } + + /** + * Print a note about affected modules that do not have tests. + * + * @param string[] $modulesWithoutTests + * Affected modules without tests. + */ + private function printModulesWithoutTests(array $modulesWithoutTests): void { + if (!$modulesWithoutTests) { + return; } - // No affected modules with tests -> nothing to run. - if (!$modulesWithTests) { - return TaskResult::createSkipped($this, $context); + $lines = []; + foreach ($modulesWithoutTests as $modulePath) { + $lines[] = ' - ' . $modulePath; } - // Provide a short hint about which modules will be tested. - // This mirrors GrumPHP's own task output style without being too noisy. + fwrite( + STDOUT, + "\nphpunit_drupal_modules: NOTE: affected modules without tests:\n" . + implode("\n", $lines) . + "\n\n" + ); + } + + /** + * Print a list of modules for which tests will be executed. + * + * @param string[] $modulesWithTests + * Affected modules with tests. + */ + private function printModulesWithTests(array $modulesWithTests): void { $lines = []; foreach ($modulesWithTests as $modulePath) { $lines[] = ' - ' . $modulePath; } + fwrite( STDOUT, "phpunit_drupal_modules: running tests for modules:\n" . implode("\n", $lines) . "\n\n" ); - - $process = $this->processBuilder->buildProcess($this->buildArguments($modulesWithTests)); - $process->run(); - - return $this->getTaskResult($process, $context); } /** From 478d369751edd8257ea63143c0c4728eeb936082 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 12:42:46 +0200 Subject: [PATCH 16/24] #119: Run phpunit per affected Drupal module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Execute phpunit once per affected custom Drupal module so each module’s tests run with the configured testsuite - Keep clear output about modules with and without tests while stopping on the first failing module run Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 87332c9..af037fd 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -39,12 +39,22 @@ public function run(ContextInterface $context): TaskResultInterface { return TaskResult::createSkipped($this, $context); } + // Run phpunit once per affected module so that each module's directory + // is honoured even when a testsuite is used in the configuration file. $this->printModulesWithTests($modulesWithTests); - $process = $this->processBuilder->buildProcess($this->buildArguments($modulesWithTests)); - $process->run(); + foreach ($modulesWithTests as $modulePath) { + $process = $this->processBuilder->buildProcess($this->buildArguments([$modulePath])); + $process->run(); + + $result = $this->getTaskResult($process, $context); + if (!$result->isPassed()) { + // Stop on first failure/error to keep feedback fast and clear. + return $result; + } + } - return $this->getTaskResult($process, $context); + return TaskResult::createPassed($this, $context); } /** From ba7a75212e37834d11b75aab0199b919de1e0b16 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 15:31:42 +0200 Subject: [PATCH 17/24] #119: Log per-module progress in Drupal phpunit task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Print a progress line after each affected Drupal module’s phpunit run, including index, total, module path, and OK/FAILED status - Keep per-module execution behavior while maintaining early exit on the first failing module Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php --- .../PhpUnitDrupalModulesTask.php | 16 ++- .../PhpUnitDrupalModulesTaskTest.php | 115 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index af037fd..a0e9cd3 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -43,11 +43,25 @@ public function run(ContextInterface $context): TaskResultInterface { // is honoured even when a testsuite is used in the configuration file. $this->printModulesWithTests($modulesWithTests); - foreach ($modulesWithTests as $modulePath) { + $moduleCount = count($modulesWithTests); + + foreach ($modulesWithTests as $index => $modulePath) { $process = $this->processBuilder->buildProcess($this->buildArguments([$modulePath])); $process->run(); $result = $this->getTaskResult($process, $context); + + fwrite( + STDOUT, + sprintf( + "phpunit_drupal_modules: finished module %d/%d: %s [%s]\n\n", + $index + 1, + $moduleCount, + $modulePath, + $result->isPassed() ? 'OK' : 'FAILED' + ) + ); + if (!$result->isPassed()) { // Stop on first failure/error to keep feedback fast and clear. return $result; diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index e1240ed..0c69ccd 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -10,7 +10,10 @@ use GrumPHP\Collection\ProcessArgumentsCollection; use GrumPHP\Formatter\ProcessFormatterInterface; use GrumPHP\Process\ProcessBuilder; +use GrumPHP\Runner\TaskResultInterface; use GrumPHP\Task\Config\TaskConfigInterface; +use GrumPHP\Task\Context\ContextInterface; +use Symfony\Component\Process\Process; use PHPUnit\Framework\TestCase; use Symfony\Component\Yaml\Yaml; use Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask; @@ -130,6 +133,118 @@ public function testBuildsProcessArgumentsWithOnlyModules(): void { $this->assertInstanceOf(ProcessArgumentsCollection::class, $actual); } + /** + * Ensure run() skips when there are no modules with tests. + * + * @covers \Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask::run + */ + public function testRunSkipsWhenNoModulesWithTests(): void { + $processBuilder = $this->createMock(ProcessBuilder::class); + $formatter = $this->createMock(ProcessFormatterInterface::class); + + /** @var \PHPUnit\Framework\MockObject\MockObject|PhpUnitDrupalModulesTask $task */ + $task = $this->getMockBuilder(PhpUnitDrupalModulesTask::class) + ->setConstructorArgs([$processBuilder, $formatter]) + ->onlyMethods(['getConfig', 'getPathsOrResult', 'collectModulesFromPaths', 'splitModulesByTests']) + ->getMock(); + + $config = []; + foreach ($this->getConfigurations() as $name => $option) { + $config[$name] = $option['defaults']; + } + + $taskConfig = $this->createMock(TaskConfigInterface::class); + $task->method('getConfig')->willReturn($taskConfig); + $taskConfig->method('getOptions')->willReturn($config); + + $context = $this->createMock(ContextInterface::class); + + $paths = new \ArrayObject(['web/modules/custom/foo/src/Foo.php']); + $task->method('getPathsOrResult')->willReturn($paths); + $task->method('collectModulesFromPaths')->willReturn([ + 'web/modules/custom/foo' => 'web/modules/custom/foo', + ]); + + // No modules with tests, one without. + $task->method('splitModulesByTests')->willReturn([[], ['web/modules/custom/foo']]); + + $result = $task->run($context); + $this->assertInstanceOf(TaskResultInterface::class, $result); + $this->assertFalse($result->isPassed()); + } + + /** + * Ensure run() executes phpunit once per module and stops on first failure. + * + * @covers \Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask::run + */ + public function testRunExecutesPhpunitPerModuleAndStopsOnFailure(): void { + $processBuilder = $this->createMock(ProcessBuilder::class); + $formatter = $this->createMock(ProcessFormatterInterface::class); + + /** @var \PHPUnit\Framework\MockObject\MockObject|PhpUnitDrupalModulesTask $task */ + $task = $this->getMockBuilder(PhpUnitDrupalModulesTask::class) + ->setConstructorArgs([$processBuilder, $formatter]) + ->onlyMethods([ + 'getConfig', + 'getPathsOrResult', + 'collectModulesFromPaths', + 'splitModulesByTests', + 'buildArguments', + 'getTaskResult', + ]) + ->getMock(); + + $config = []; + foreach ($this->getConfigurations() as $name => $option) { + $config[$name] = $option['defaults']; + } + + $taskConfig = $this->createMock(TaskConfigInterface::class); + $task->method('getConfig')->willReturn($taskConfig); + $taskConfig->method('getOptions')->willReturn($config); + + $context = $this->createMock(ContextInterface::class); + + $paths = new \ArrayObject([ + 'web/modules/custom/foo/src/Foo.php', + 'web/modules/custom/bar/src/Bar.php', + ]); + $task->method('getPathsOrResult')->willReturn($paths); + $task->method('collectModulesFromPaths')->willReturn([ + 'web/modules/custom/foo' => 'web/modules/custom/foo', + 'web/modules/custom/bar' => 'web/modules/custom/bar', + ]); + + $modulesWithTests = ['web/modules/custom/foo', 'web/modules/custom/bar']; + $modulesWithoutTests = []; + $task->method('splitModulesByTests')->willReturn([$modulesWithTests, $modulesWithoutTests]); + + // Expect buildArguments to be called once per module with a single-element + // array. + $task->expects($this->exactly(2)) + ->method('buildArguments') + ->withConsecutive( + [['web/modules/custom/foo']], + [['web/modules/custom/bar']] + ) + ->willReturn($this->createMock(ProcessArgumentsCollection::class)); + + $processBuilder->method('buildProcess') + ->willReturn($this->createMock(Process::class)); + + // Simulate first module passing, second failing. + $passingResult = $this->createConfiguredMock(TaskResultInterface::class, ['isPassed' => TRUE]); + $failingResult = $this->createConfiguredMock(TaskResultInterface::class, ['isPassed' => FALSE]); + + $task->expects($this->exactly(2)) + ->method('getTaskResult') + ->willReturnOnConsecutiveCalls($passingResult, $failingResult); + + $result = $task->run($context); + $this->assertSame($failingResult, $result); + } + /** * Gets task configurations. * From 18cf0f56190c2e5bdd06dcb31f36e07cb76d626a Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 15:48:17 +0200 Subject: [PATCH 18/24] #119: Add timeout option for Drupal modules phpunit task - Introduce a configurable timeout option for the PhpUnitDrupalModules task and apply it per-module via Symfony Process - Keep existing config_file and testsuite handling while improving progress output with per-module completion lines Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php, src/Task/tasks.yml --- src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php | 6 ++++++ src/Task/tasks.yml | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index a0e9cd3..6739aa4 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -44,9 +44,15 @@ public function run(ContextInterface $context): TaskResultInterface { $this->printModulesWithTests($modulesWithTests); $moduleCount = count($modulesWithTests); + $timeout = $config['timeout'] ?? NULL; foreach ($modulesWithTests as $index => $modulePath) { $process = $this->processBuilder->buildProcess($this->buildArguments([$modulePath])); + + if ($timeout !== NULL) { + $process->setTimeout($timeout); + } + $process->run(); $result = $this->getTaskResult($process, $context); diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index d118b5e..48609f4 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -305,3 +305,6 @@ Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: testsuite: defaults: ~ allowed_types: ['string', 'null'] + timeout: + defaults: ~ + allowed_types: ['int', 'null'] From e3c58374f5cb6d0345b76319c65f7ead30bcdd45 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 15:55:59 +0200 Subject: [PATCH 19/24] #119: Add integration test for Drupal modules phpunit task - Add an end-to-end test that uses a temporary directory tree to verify module root detection from changed paths - Assert that modules are correctly split into with-tests and without-tests based on presence of a tests/ directory Refs: tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../PhpUnitDrupalModulesTaskTest.php | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index 0c69ccd..93fb10a 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -173,6 +173,96 @@ public function testRunSkipsWhenNoModulesWithTests(): void { $this->assertFalse($result->isPassed()); } + /** + * Integration-style test to verify module detection and tests split end-to-end. + * + * This test uses a real temporary directory structure and the actual implementations + * of collectModulesFromPaths() and splitModulesByTests(). + */ + public function testCollectAndSplitModulesEndToEndWithRealPaths(): void { + $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpunit_drupal_modules_' . uniqid('', true); + + $moduleWithTests = $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . 'foo'; + $moduleWithoutTests = $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . 'bar'; + + $directories = [ + $moduleWithTests . DIRECTORY_SEPARATOR . 'src', + $moduleWithTests . DIRECTORY_SEPARATOR . 'tests', + $moduleWithoutTests . DIRECTORY_SEPARATOR . 'src', + ]; + + try { + foreach ($directories as $dir) { + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + $this->fail(sprintf('Failed to create directory: %s', $dir)); + } + } + + // Create dummy PHP files to simulate changed source files. + $fooSrcFile = $moduleWithTests . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Foo.php'; + $barSrcFile = $moduleWithoutTests . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Bar.php'; + + file_put_contents($fooSrcFile, "createMock(ProcessBuilder::class); + $formatter = $this->createMock(ProcessFormatterInterface::class); + + // Use an anonymous class to expose the protected/private methods without mocking them. + $task = new class($processBuilder, $formatter) extends PhpUnitDrupalModulesTask { + public function exposeCollectModulesFromPaths(\Traversable $paths): array { + return $this->collectModulesFromPaths($paths); + } + + public function exposeSplitModulesByTests(array $modules): array { + return $this->splitModulesByTests($modules); + } + }; + + $modules = $task->exposeCollectModulesFromPaths($paths); + + // Ensure both module roots were detected. + $this->assertIsArray($modules); + $this->assertArrayHasKey($moduleWithTests, $modules); + $this->assertArrayHasKey($moduleWithoutTests, $modules); + + [$withTests, $withoutTests] = $task->exposeSplitModulesByTests($modules); + + $this->assertContains($moduleWithTests, $withTests, 'Module with tests/ directory should be in with-tests list.'); + $this->assertContains($moduleWithoutTests, $withoutTests, 'Module without tests/ directory should be in without-tests list.'); + } + finally { + // Clean up the temporary directory structure. + if (is_dir($tempRoot)) { + $remove = function (string $dir) use (&$remove): void { + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + $remove($path); + } + else { + @unlink($path); + } + } + @rmdir($dir); + }; + $remove($tempRoot); + } + } + } + /** * Ensure run() executes phpunit once per module and stops on first failure. * From 42dd8505737633e3dda485923c8c1bd9b70079f1 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 15:59:55 +0200 Subject: [PATCH 20/24] #119: Improve visibility and configuration of Drupal modules phpunit task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the task’s actual name in all console messages instead of hardcoded strings - Add a configurable timeout option for per-module phpunit runs and apply it via Symfony Process - Print per-module completion lines with index, total, and OK/FAILED status to show progress during long test runs Refs: src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php, src/Task/tasks.yml --- .../PhpUnitDrupalModulesTask.php | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 6739aa4..931923f 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -60,7 +60,8 @@ public function run(ContextInterface $context): TaskResultInterface { fwrite( STDOUT, sprintf( - "phpunit_drupal_modules: finished module %d/%d: %s [%s]\n\n", + "%s: finished module %d/%d: %s [%s]\n\n", + $this->getName(), $index + 1, $moduleCount, $modulePath, @@ -169,9 +170,11 @@ private function printModulesWithoutTests(array $modulesWithoutTests): void { fwrite( STDOUT, - "\nphpunit_drupal_modules: NOTE: affected modules without tests:\n" . - implode("\n", $lines) . - "\n\n" + sprintf( + "\n%s: NOTE: affected modules without tests:\n%s\n\n", + $this->getName(), + implode("\n", $lines) + ) ); } @@ -189,9 +192,11 @@ private function printModulesWithTests(array $modulesWithTests): void { fwrite( STDOUT, - "phpunit_drupal_modules: running tests for modules:\n" . - implode("\n", $lines) . - "\n\n" + sprintf( + "%s: running tests for modules:\n%s\n\n", + $this->getName(), + implode("\n", $lines) + ) ); } From a1a54e5d5553afe7ef5fcadabb6f7b359b0fd4e8 Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 16:10:03 +0200 Subject: [PATCH 21/24] #119 Fix tests and coding standards. --- .../PhpUnitDrupalModulesTask.php | 4 +-- .../PhpUnitDrupalModulesTaskTest.php | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php index 931923f..1ebca3f 100644 --- a/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesTask.php @@ -110,7 +110,7 @@ private function getModuleRoots(array $config): array { * @return string[] * Module paths keyed by path for uniqueness. */ - private function collectModulesFromPaths(iterable $paths, array $moduleRoots): array { + protected function collectModulesFromPaths(iterable $paths, array $moduleRoots): array { $modules = []; foreach ($paths as $file) { $path = (string) $file; @@ -139,7 +139,7 @@ private function collectModulesFromPaths(iterable $paths, array $moduleRoots): a * @return array{0: string[], 1: string[]} * First array contains modules with tests, second without. */ - private function splitModulesByTests(array $modules): array { + protected function splitModulesByTests(array $modules): array { $modulesWithTests = []; foreach ($modules as $modulePath) { if (is_dir($modulePath . '/tests')) { diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index 93fb10a..7347620 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -174,13 +174,13 @@ public function testRunSkipsWhenNoModulesWithTests(): void { } /** - * Integration-style test to verify module detection and tests split end-to-end. + * Integration test for module detection and tests split end-to-end. * - * This test uses a real temporary directory structure and the actual implementations - * of collectModulesFromPaths() and splitModulesByTests(). + * This test uses a real temporary directory structure and the actual + * implementations of collectModulesFromPaths() and splitModulesByTests(). */ public function testCollectAndSplitModulesEndToEndWithRealPaths(): void { - $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpunit_drupal_modules_' . uniqid('', true); + $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpunit_drupal_modules_' . uniqid('', TRUE); $moduleWithTests = $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . 'foo'; $moduleWithoutTests = $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . 'bar'; @@ -193,7 +193,7 @@ public function testCollectAndSplitModulesEndToEndWithRealPaths(): void { try { foreach ($directories as $dir) { - if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + if (!is_dir($dir) && !mkdir($dir, 0777, TRUE) && !is_dir($dir)) { $this->fail(sprintf('Failed to create directory: %s', $dir)); } } @@ -213,15 +213,24 @@ public function testCollectAndSplitModulesEndToEndWithRealPaths(): void { $processBuilder = $this->createMock(ProcessBuilder::class); $formatter = $this->createMock(ProcessFormatterInterface::class); - // Use an anonymous class to expose the protected/private methods without mocking them. + // Use an anonymous class to expose the protected/private methods + // without mocking them. $task = new class($processBuilder, $formatter) extends PhpUnitDrupalModulesTask { + + /** + * Proxy to collectModulesFromPaths() for testing. + */ public function exposeCollectModulesFromPaths(\Traversable $paths): array { return $this->collectModulesFromPaths($paths); } + /** + * Proxy to splitModulesByTests() for testing. + */ public function exposeSplitModulesByTests(array $modules): array { return $this->splitModulesByTests($modules); } + }; $modules = $task->exposeCollectModulesFromPaths($paths); @@ -233,15 +242,23 @@ public function exposeSplitModulesByTests(array $modules): array { [$withTests, $withoutTests] = $task->exposeSplitModulesByTests($modules); - $this->assertContains($moduleWithTests, $withTests, 'Module with tests/ directory should be in with-tests list.'); - $this->assertContains($moduleWithoutTests, $withoutTests, 'Module without tests/ directory should be in without-tests list.'); + $this->assertContains( + $moduleWithTests, + $withTests, + 'Module with tests directory should be in with-tests list.' + ); + $this->assertContains( + $moduleWithoutTests, + $withoutTests, + 'Module without tests directory should be in without-tests list.' + ); } finally { // Clean up the temporary directory structure. if (is_dir($tempRoot)) { $remove = function (string $dir) use (&$remove): void { $items = scandir($dir); - if ($items === false) { + if ($items === FALSE) { return; } foreach ($items as $item) { From 8b175c867198ad816bee82f2ca7ff10805c6d00f Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 16:13:30 +0200 Subject: [PATCH 22/24] #119 Fix tests. --- .../PhpUnitDrupalModulesTaskTest.php | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index 7347620..bf8a836 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -11,6 +11,7 @@ use GrumPHP\Formatter\ProcessFormatterInterface; use GrumPHP\Process\ProcessBuilder; use GrumPHP\Runner\TaskResultInterface; +use GrumPHP\Runner\TaskResult; use GrumPHP\Task\Config\TaskConfigInterface; use GrumPHP\Task\Context\ContextInterface; use Symfony\Component\Process\Process; @@ -219,9 +220,17 @@ public function testCollectAndSplitModulesEndToEndWithRealPaths(): void { /** * Proxy to collectModulesFromPaths() for testing. + * + * @param \Traversable $paths + * Changed paths. + * @param string[] $moduleRoots + * Module root directories. + * + * @return string[] + * Module paths keyed by path for uniqueness. */ - public function exposeCollectModulesFromPaths(\Traversable $paths): array { - return $this->collectModulesFromPaths($paths); + public function exposeCollectModulesFromPaths(\Traversable $paths, array $moduleRoots): array { + return $this->collectModulesFromPaths($paths, $moduleRoots); } /** @@ -233,7 +242,9 @@ public function exposeSplitModulesByTests(array $modules): array { }; - $modules = $task->exposeCollectModulesFromPaths($paths); + $modules = $task->exposeCollectModulesFromPaths($paths, [ + $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom', + ]); // Ensure both module roots were detected. $this->assertIsArray($modules); @@ -341,8 +352,8 @@ public function testRunExecutesPhpunitPerModuleAndStopsOnFailure(): void { ->willReturn($this->createMock(Process::class)); // Simulate first module passing, second failing. - $passingResult = $this->createConfiguredMock(TaskResultInterface::class, ['isPassed' => TRUE]); - $failingResult = $this->createConfiguredMock(TaskResultInterface::class, ['isPassed' => FALSE]); + $passingResult = $this->createConfiguredMock(TaskResult::class, ['isPassed' => TRUE]); + $failingResult = $this->createConfiguredMock(TaskResult::class, ['isPassed' => FALSE]); $task->expects($this->exactly(2)) ->method('getTaskResult') From 7a2403fc8fcc8753f41264ea173bd0b44de2586c Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 16:23:05 +0200 Subject: [PATCH 23/24] #119: Assert skipped result code - update phpunit task test to assert SKIPPED result code instead of relying on isPassed() Refs: tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php --- tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php index bf8a836..a61ee11 100644 --- a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -171,7 +171,7 @@ public function testRunSkipsWhenNoModulesWithTests(): void { $result = $task->run($context); $this->assertInstanceOf(TaskResultInterface::class, $result); - $this->assertFalse($result->isPassed()); + $this->assertSame(TaskResult::SKIPPED, $result->getResultCode()); } /** From 168823183bb1899c9d9270924606b7a67c8ddf8f Mon Sep 17 00:00:00 2001 From: Hannes Kirsman Date: Tue, 17 Mar 2026 16:32:33 +0200 Subject: [PATCH 24/24] #119 We phpunit_drupal_modules task name as it's used on screen. --- src/Task/tasks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index 48609f4..d6a02e8 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -283,6 +283,7 @@ Wunderio\GrumPHP\Task\Psalm\PsalmTask: allowed_types: ['bool'] Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: + name: phpunit_drupal_modules is_file_specific: true options: ignore_patterns: