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..3c5fc3c --- /dev/null +++ b/src/Task/PhpUnitDrupalModules/PhpUnitDrupalModulesExtensionLoader.php @@ -0,0 +1,12 @@ +getConfig()->getOptions(); + $paths = $this->getPathsOrResult($context, $config, $this); + + if ($paths instanceof TaskResultInterface) { + return $paths; + } + + $moduleRoots = $this->getModuleRoots($config); + $modules = $this->collectModulesFromPaths($paths, $moduleRoots); + + [$modulesWithTests, $modulesWithoutTests] = $this->splitModulesByTests($modules); + + $this->printModulesWithoutTests($modulesWithoutTests); + + if (!$modulesWithTests) { + 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); + + $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); + + fwrite( + STDOUT, + sprintf( + "%s: finished module %d/%d: %s [%s]\n\n", + $this->getName(), + $index + 1, + $moduleCount, + $modulePath, + $result->isPassed() ? 'OK' : 'FAILED' + ) + ); + + if (!$result->isPassed()) { + // Stop on first failure/error to keep feedback fast and clear. + return $result; + } + } + + return TaskResult::createPassed($this, $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, '/'); + } + 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. + */ + protected function collectModulesFromPaths(iterable $paths, array $moduleRoots): array { + $modules = []; + foreach ($paths as $file) { + $path = (string) $file; + foreach ($moduleRoots as $root) { + $rootWithSlash = $root . '/'; + + if (!str_starts_with($path, $rootWithSlash)) { + continue; + } + + if (preg_match('#^(' . preg_quote($root, '#') . '/[^/]+)#', $path, $matches)) { + $modules[$matches[1]] = $matches[1]; + } + } + } + + 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. + */ + protected function splitModulesByTests(array $modules): array { + $modulesWithTests = []; + foreach ($modules as $modulePath) { + if (is_dir($modulePath . '/tests')) { + $modulesWithTests[] = $modulePath; + } + } + + $modulesWithoutTests = array_values(array_diff($modules, $modulesWithTests)); + + 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; + } + + $lines = []; + foreach ($modulesWithoutTests as $modulePath) { + $lines[] = ' - ' . $modulePath; + } + + fwrite( + STDOUT, + sprintf( + "\n%s: NOTE: affected modules without tests:\n%s\n\n", + $this->getName(), + implode("\n", $lines) + ) + ); + } + + /** + * 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, + sprintf( + "%s: running tests for modules:\n%s\n\n", + $this->getName(), + implode("\n", $lines) + ) + ); + } + + /** + * {@inheritdoc} + */ + public function buildArguments(iterable $modules): ProcessArgumentsCollection { + $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']); + } + + if (!empty($config['testsuite'])) { + $arguments->add('--testsuite'); + $arguments->add($config['testsuite']); + } + + foreach ($modules as $modulePath) { + $arguments->add($modulePath); + } + + return $arguments; + } + +} diff --git a/src/Task/PhpUnitDrupalModules/services.yaml b/src/Task/PhpUnitDrupalModules/services.yaml new file mode 100644 index 0000000..a9e3759 --- /dev/null +++ b/src/Task/PhpUnitDrupalModules/services.yaml @@ -0,0 +1,9 @@ +services: + Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: + class: Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - { name: grumphp.task, task: phpunit_drupal_modules } + diff --git a/src/Task/tasks.yml b/src/Task/tasks.yml index e750c62..d6a02e8 100644 --- a/src/Task/tasks.yml +++ b/src/Task/tasks.yml @@ -281,3 +281,31 @@ Wunderio\GrumPHP\Task\Psalm\PsalmTask: show_info: defaults: false allowed_types: ['bool'] + +Wunderio\GrumPHP\Task\PhpUnitDrupalModules\PhpUnitDrupalModulesTask: + name: phpunit_drupal_modules + 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'] + config_file: + defaults: ~ + allowed_types: ['string', 'null'] + testsuite: + defaults: ~ + allowed_types: ['string', 'null'] + timeout: + defaults: ~ + allowed_types: ['int', 'null'] diff --git a/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php new file mode 100644 index 0000000..a61ee11 --- /dev/null +++ b/tests/PhpUnitDrupalModules/PhpUnitDrupalModulesTaskTest.php @@ -0,0 +1,377 @@ +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); + } + + /** + * 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->assertSame(TaskResult::SKIPPED, $result->getResultCode()); + } + + /** + * 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(). + */ + 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 { + + /** + * 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 $moduleRoots): array { + return $this->collectModulesFromPaths($paths, $moduleRoots); + } + + /** + * Proxy to splitModulesByTests() for testing. + */ + public function exposeSplitModulesByTests(array $modules): array { + return $this->splitModulesByTests($modules); + } + + }; + + $modules = $task->exposeCollectModulesFromPaths($paths, [ + $tempRoot . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'custom', + ]); + + // 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. + * + * @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(TaskResult::class, ['isPassed' => TRUE]); + $failingResult = $this->createConfiguredMock(TaskResult::class, ['isPassed' => FALSE]); + + $task->expects($this->exactly(2)) + ->method('getTaskResult') + ->willReturnOnConsecutiveCalls($passingResult, $failingResult); + + $result = $task->run($context); + $this->assertSame($failingResult, $result); + } + + /** + * 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']; + } + +}