diff --git a/components/ILIAS/Setup/README.md b/components/ILIAS/Setup/README.md index 6a93ce9e72b3..b64f634a1147 100755 --- a/components/ILIAS/Setup/README.md +++ b/components/ILIAS/Setup/README.md @@ -326,6 +326,17 @@ system where ILIAS was not installed, for example, the output only contains the information that ilias is not installed. The command also reports on the configuration of the installation. +Use the `-f` option to only output a part of the status. The filter is the path to +a metric, separated by dots, e.g. `php cli/setup.php status -f database.version`. +The first part of the path names an agent, and only that agent is asked for its +status. For an agent of the core, the agents of the plugins are not even looked up, +so a filtered call is considerably faster than a complete one. +Metrics of the configuration are shown in their own section `config`, which is why +a filter starting with `config.` looks into that section, e.g. +`php cli/setup.php status -f config.common` only outputs the common configuration. +Consequently, `config` cannot be used as the key of an agent. +If the path does not exist, the command says so and exits with a non-zero code. + The output of the command is formatted as YAML to be easily readable by people and machines. So we encourage you to use this command for monitoring your system and also request status information via our feature process that you are interested in. diff --git a/components/ILIAS/Setup/src/AgentCollection.php b/components/ILIAS/Setup/src/AgentCollection.php index 098824e76b03..32e712294e1a 100755 --- a/components/ILIAS/Setup/src/AgentCollection.php +++ b/components/ILIAS/Setup/src/AgentCollection.php @@ -55,6 +55,20 @@ public function withRemovedAgent(string $key): AgentCollection return $clone; } + /** + * A collection that only contains the agent with the given key, still under + * that key, so everything it stores keeps its path. + */ + public function withOnlyAgent(string $key): AgentCollection + { + if (!isset($this->agents[$key])) { + throw new \InvalidArgumentException("There is no agent with the name '$key'."); + } + $clone = clone $this; + $clone->agents = [$key => $this->agents[$key]]; + return $clone; + } + public function withAdditionalAgent(string $key, Agent $agent): AgentCollection { if (isset($this->agents[$key])) { diff --git a/components/ILIAS/Setup/src/CLI/MetricsFilterException.php b/components/ILIAS/Setup/src/CLI/MetricsFilterException.php new file mode 100644 index 000000000000..be1f83bf5fd5 --- /dev/null +++ b/components/ILIAS/Setup/src/CLI/MetricsFilterException.php @@ -0,0 +1,28 @@ +setDescription("Collect and show status information about the installation."); $this->configureCommandForPlugins(); + $this->addOption( + "filter", + "f", + InputOption::VALUE_REQUIRED, + "Only show the metrics on this path, e.g. \"database\" or \"config.common\"." + ); } protected function execute(InputInterface $input, OutputInterface $output): int { - $agent = $this->getRelevantAgent($input); + $filter = (string) ($input->getOption("filter") ?? ""); + + try { + $metrics = $this->getMetrics($this->getAgentFor($input, $filter), $filter); + } catch (MetricsFilterException $e) { + $output->writeln("" . $e->getMessage() . ""); + return Command::FAILURE; + } + + $output->write($metrics->toYAML() . "\n"); + + return Command::SUCCESS; + } + + /** + * The first part of a filter names an agent. Only that agent is built and asked + * for its status, which spares the others and, for an agent of the core, even + * finding the agents of the plugins. + */ + protected function getAgentFor(InputInterface $input, string $filter): Agent + { + $key = $this->getAgentKeyOf($filter); + if ($key === null + || ($input->hasOption("legacy-plugin") && $input->getOption("legacy-plugin"))) { + return $this->getRelevantAgent($input); + } + + $component_agents = $this->agent_finder->getComponentAgents(); + if ($component_agents->getAgent($key) !== null) { + return $component_agents->withOnlyAgent($key); + } + + $agents = $this->getRelevantAgent($input); + if (!$agents instanceof AgentCollection) { + return $agents; + } + if ($agents->getAgent($key) === null) { + throw new MetricsFilterException( + "There is no metric '$key'. Run the command without a filter to see which metrics are available." + ); + } + return $agents->withOnlyAgent($key); + } - $output->write($this->getMetrics($agent)->toYAML() . "\n"); + protected function getAgentKeyOf(string $filter): ?string + { + $path = $this->getPathOf($filter); + return $path === [] ? null : $path[0]; + } - return 0; + /** + * @return string[] + */ + protected function getPathOf(string $filter): array + { + $path = array_values(array_filter(explode(".", $filter), fn(string $p): bool => $p !== "")); + if (($path[0] ?? null) === "config") { + array_shift($path); + } + return $path; } - public function getMetrics(Agent $agent): Metrics\Metric + public function getMetrics(Agent $agent, string $filter = ""): Metrics\Metric { // ATTENTION: Don't do this (in general), please have a look at the comment // in ilIniFilesLoadedObjective. @@ -75,21 +138,59 @@ public function getMetrics(Agent $agent): Metrics\Metric $this->achieveObjective($objective, $environment); - $metric = $storage->asMetric(); + $show_config_section = explode(".", $filter)[0] === "config"; + $metric = $this->select($this->getPathOf($filter), $storage->asMetric()); + + $type = Metrics\Metric::TYPE_COLLECTION; list($config, $other) = $metric->extractByStability(Metrics\Metric::STABILITY_CONFIG); - if ($other) { + $values = []; + if ($other && !$show_config_section) { $values = $other->getValue(); - } else { - $values = []; + $type = $other->getType(); } if ($config) { - $values["config"] = $config; + if ($show_config_section) { + $values = $config->getValue(); + $type = $config->getType(); + } else { + $values["config"] = $config; + } } return new Metrics\Metric( - Metrics\Metric::STABILITY_MIXED, - Metrics\Metric::TYPE_COLLECTION, + $type === Metrics\Metric::TYPE_COLLECTION + ? Metrics\Metric::STABILITY_MIXED + : Metrics\Metric::STABILITY_VOLATILE, + $type, $values ); } + + /** + * @param string[] $path + */ + protected function select(array $path, Metrics\Metric $metric): Metrics\Metric + { + $done = []; + foreach ($path as $key) { + if ($metric->getType() !== Metrics\Metric::TYPE_COLLECTION) { + throw new MetricsFilterException( + "Cannot filter by '$key': " + . ($done === [] ? "the status" : "'" . implode(".", $done) . "'") + . " is a single value, not a collection." + ); + } + $metrics = $metric->getValue(); + if (!array_key_exists($key, $metrics)) { + throw new MetricsFilterException( + "There is no metric '" . implode(".", [...$done, $key]) . "'. Available" + . ($done === [] ? "" : " below '" . implode(".", $done) . "'") + . ": " . implode(", ", array_keys($metrics)) + ); + } + $metric = $metrics[$key]; + $done[] = $key; + } + return $metric; + } } diff --git a/components/ILIAS/Setup/src/ImplementationOfAgentFinder.php b/components/ILIAS/Setup/src/ImplementationOfAgentFinder.php index bf2e1f271557..70a8a4f7a64b 100755 --- a/components/ILIAS/Setup/src/ImplementationOfAgentFinder.php +++ b/components/ILIAS/Setup/src/ImplementationOfAgentFinder.php @@ -25,16 +25,21 @@ class ImplementationOfAgentFinder implements AgentFinder { + protected const string PLUGIN_PATH = "[/]public/Customizing/global/plugins/"; + protected array|AgentCollection $component_agents; + protected string $plugin_directory; public function __construct( protected Refinery $refinery, protected Data\Factory $data_factory, protected \ILIAS\Language\Language $lng, protected ImplementationOfInterfaceFinder $interface_finder, - $component_agents + $component_agents, + ?string $plugin_directory = null ) { $this->component_agents = $component_agents; + $this->plugin_directory = $plugin_directory ?? __DIR__ . "/../../../../public/Customizing/global/plugins"; } /** @@ -46,13 +51,20 @@ public function getAgents(): AgentCollection { $agents = $this->getComponentAgents(); - // Get a list of existing plugins in the system. - $plugins = $this->getPluginNames(); + // Searching the classes of all plugins at once, a search per plugin would + // go through every class of the system once for every plugin. + $agent_classes_by_plugin = []; + foreach ($this->interface_finder->getMatchingClassNames(Agent::class, [], self::PLUGIN_PATH . ".*") as $class_name) { + $plugin_name = $this->getPluginNameOfClass($class_name); + if ($plugin_name !== null) { + $agent_classes_by_plugin[$plugin_name][] = $class_name; + } + } - foreach ($plugins as $plugin_name) { + foreach ($this->getPluginNames() as $plugin_name) { $agents = $agents->withAdditionalAgent( $plugin_name, - $this->getPluginAgent($plugin_name) + $this->buildPluginAgent($plugin_name, $agent_classes_by_plugin[$plugin_name] ?? []) ); } @@ -98,13 +110,23 @@ public function getPluginAgent(string $name): Agent // TODO: This seems to be something that rather belongs to Services/Component/ // but we put it here anyway for the moment. This seems to be something that // could go away when we unify Services/Modules/Plugins to one common concept. - $path = "[/]public/Customizing/global/plugins/.*/.*/" . $name . "/.*"; - $agent_classes = iterator_to_array($this->interface_finder->getMatchingClassNames( - Agent::class, - [], - $path - )); + $agent_classes = array_filter( + iterator_to_array($this->interface_finder->getMatchingClassNames( + Agent::class, + [], + self::PLUGIN_PATH . ".*/.*/" . $name . "/.*" + )), + fn(string $class_name): bool => $this->getPluginNameOfClass($class_name) === $name + ); + return $this->buildPluginAgent($name, array_values($agent_classes)); + } + + /** + * @param string[] $agent_classes + */ + protected function buildPluginAgent(string $name, array $agent_classes): Agent + { if ($agent_classes === []) { return new class ($name) extends \ilPluginDefaultAgent { }; @@ -133,6 +155,20 @@ public function getPluginAgent(string $name): Agent return new \ilPluginAgentWithDefaultObjectives($name, $agent); } + /** + * The name of the plugin a class belongs to, or null if it is no class of a plugin. + */ + protected function getPluginNameOfClass(string $class_name): ?string + { + $file = (new \ReflectionClass($class_name))->getFileName(); + $groups = []; + if ($file !== false + && preg_match("%/Customizing/global/plugins/(?:Services|Modules)/\\w+/\\w+/([^/.]+)/%", $file, $groups)) { + return $groups[1]; + } + return null; + } + public function getAgentByClassName(string $class_name): Agent { if (!class_exists($class_name)) { @@ -165,21 +201,21 @@ public function getAgentNameByClassName(string $class_name): string */ protected function getPluginNames(): \Generator { - $directories = - new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator(__DIR__ . "/../../../../public/Customizing/global/plugins") - ); + // Plugins live exactly four levels below the plugin directory, so there is + // no need to walk through every file of every plugin (including their + // vendor directories) to find them. + $directories = array_merge( + glob($this->plugin_directory . "/Services/*/*/*", GLOB_ONLYDIR) ?: [], + glob($this->plugin_directory . "/Modules/*/*/*", GLOB_ONLYDIR) ?: [] + ); $names = []; foreach ($directories as $dir) { - $groups = []; - if (preg_match("%^" . __DIR__ . "/[.][.]/[.][.]/[.][.]/[.][.]/public/Customizing/global/plugins/(Services|Modules)/((\\w+/){2})([^/\.]+)(/|$)%", (string) $dir, $groups)) { - $name = $groups[4]; - if (isset($names[$name])) { - continue; - } - $names[$name] = true; - yield $name; + $name = basename($dir); + if (!preg_match("%/\\w+/\\w+/[^/.]+$%", $dir) || isset($names[$name])) { + continue; } + $names[$name] = true; + yield $name; } } } diff --git a/components/ILIAS/Setup/tests/AgentCollectionTest.php b/components/ILIAS/Setup/tests/AgentCollectionTest.php index 12be7414b581..fe983603d3f5 100755 --- a/components/ILIAS/Setup/tests/AgentCollectionTest.php +++ b/components/ILIAS/Setup/tests/AgentCollectionTest.php @@ -514,4 +514,26 @@ public function testGetAgents(): void "c4" => $c4 ], $agentCollection->getAgents()); } + + public function testWithOnlyAgentKeepsTheAgentUnderItsKey(): void + { + $refinery = new Refinery($this->createStub(DataFactory::class), $this->createStub(\ilLanguage::class)); + $database = $this->createStub(Setup\Agent::class); + $language = $this->createStub(Setup\Agent::class); + $collection = new Setup\AgentCollection($refinery, ['database' => $database, 'language' => $language]); + + $only = $collection->withOnlyAgent('database'); + + $this->assertSame(['database' => $database], $only->getAgents()); + $this->assertSame(['database' => $database, 'language' => $language], $collection->getAgents()); + } + + public function testWithOnlyAgentRejectsAnUnknownKey(): void + { + $refinery = new Refinery($this->createStub(DataFactory::class), $this->createStub(\ilLanguage::class)); + $collection = new Setup\AgentCollection($refinery, []); + + $this->expectException(\InvalidArgumentException::class); + $collection->withOnlyAgent('database'); + } } diff --git a/components/ILIAS/Setup/tests/CLI/StatusCommandTest.php b/components/ILIAS/Setup/tests/CLI/StatusCommandTest.php old mode 100755 new mode 100644 index 1c9eb729c09b..b74f9b6b1d28 --- a/components/ILIAS/Setup/tests/CLI/StatusCommandTest.php +++ b/components/ILIAS/Setup/tests/CLI/StatusCommandTest.php @@ -20,12 +20,15 @@ namespace ILIAS\Tests\Setup\CLI; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Language\Language; +use ILIAS\Refinery\Factory as Refinery; use ILIAS\Setup; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Tester\CommandTester; use ILIAS\Setup\Metrics; use ILIAS\Setup\Metrics\Metric as M; -use Hamcrest\Core\Set; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; class StatusCommandTest extends TestCase { @@ -48,4 +51,154 @@ public function testMetrics(): void $this->assertEquals($expected, $result); } + + public function testWithoutFilterAllAgentsReportAndConfigHasItsOwnSection(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getAgents')->willReturn($this->collection([ + 'database' => $this->reporting(fn(Metrics\Storage $s) => $s->storeVolatileText('version', '8.4')), + 'common' => $this->reporting(fn(Metrics\Storage $s) => $s->storeConfigText('client', 'default')), + ])); + + $tester = $this->runStatusCommand($finder, []); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertSame("database:\n version: 8.4\nconfig:\n common:\n client: default\n", $tester->getDisplay()); + } + + public function testFilterForAnAgentOfTheCoreDoesNotLoadPluginsOrAskOtherAgents(): void + { + $finder = $this->createMock(Setup\AgentFinder::class); + $finder->expects($this->never())->method('getAgents'); + $finder->method('getComponentAgents')->willReturn($this->collection([ + 'database' => $this->reporting(fn(Metrics\Storage $s) => $s->storeVolatileText('version', '8.4')), + 'language' => $this->notAsked(), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'database.version']); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertSame("8.4\n", $tester->getDisplay()); + } + + public function testFilterForAPluginOnlyAsksThatPlugin(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getComponentAgents')->willReturn($this->collection([ + 'database' => $this->notAsked(), + ])); + $finder->method('getAgents')->willReturn($this->collection([ + 'database' => $this->notAsked(), + 'MyPlugin' => $this->reporting(fn(Metrics\Storage $s) => $s->storeStableText('version', '1.2.3')), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'MyPlugin']); + + $this->assertSame("version: 1.2.3\n", $tester->getDisplay()); + } + + public function testFilterForTheConfigSectionOnlyShowsTheConfigOfThatAgent(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getComponentAgents')->willReturn($this->collection([ + 'common' => $this->reporting(function (Metrics\Storage $s): void { + $s->storeConfigText('client', 'default'); + $s->storeVolatileBool('is_installed', true); + }), + 'database' => $this->notAsked(), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'config.common']); + + $this->assertSame("client: default\n", $tester->getDisplay()); + } + + public function testUnknownAgentFailsWithAHint(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getComponentAgents')->willReturn($this->collection([])); + $finder->method('getAgents')->willReturn($this->collection([ + 'database' => $this->notAsked(), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'nope']); + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString("There is no metric 'nope'.", $tester->getDisplay()); + } + + public function testUnknownMetricNamesWhatIsAvailable(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getComponentAgents')->willReturn($this->collection([ + 'database' => $this->reporting(function (Metrics\Storage $s): void { + $s->storeVolatileText('version', '8.4'); + $s->storeVolatileText('engine', 'innodb'); + }), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'database.name']); + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString( + "There is no metric 'database.name'. Available below 'database': version, engine", + $tester->getDisplay() + ); + } + + public function testFilterCannotDescendIntoASingleValue(): void + { + $finder = $this->createStub(Setup\AgentFinder::class); + $finder->method('getComponentAgents')->willReturn($this->collection([ + 'database' => $this->reporting(fn(Metrics\Storage $s) => $s->storeVolatileText('version', '8.4')), + ])); + + $tester = $this->runStatusCommand($finder, ['--filter' => 'database.version.major']); + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString( + "Cannot filter by 'major': 'database.version' is a single value, not a collection.", + $tester->getDisplay() + ); + } + + private function runStatusCommand(Setup\AgentFinder $finder, array $input): CommandTester + { + $tester = new CommandTester(new Setup\CLI\StatusCommand($finder)); + $tester->execute($input); + return $tester; + } + + /** + * @param array $agents + */ + private function collection(array $agents): Setup\AgentCollection + { + return new Setup\AgentCollection( + new Refinery(new DataFactory(), $this->createStub(Language::class)), + $agents + ); + } + + /** + * @param \Closure(Metrics\Storage): void $report + */ + private function reporting(\Closure $report): Setup\Agent + { + $agent = $this->createStub(Setup\Agent::class); + $agent->method('getStatusObjective')->willReturnCallback( + function (Metrics\Storage $storage) use ($report): Setup\Objective { + $report($storage); + return new Setup\ObjectiveCollection('status', false); + } + ); + return $agent; + } + + private function notAsked(): Setup\Agent + { + $agent = $this->createMock(Setup\Agent::class); + $agent->expects($this->never())->method('getStatusObjective'); + return $agent; + } } diff --git a/components/ILIAS/Setup/tests/ImplementationOfAgentFinderTest.php b/components/ILIAS/Setup/tests/ImplementationOfAgentFinderTest.php index f48f3b79f1c5..be5abc62713d 100644 --- a/components/ILIAS/Setup/tests/ImplementationOfAgentFinderTest.php +++ b/components/ILIAS/Setup/tests/ImplementationOfAgentFinderTest.php @@ -29,96 +29,212 @@ use ILIAS\Refinery\Factory as Refinery; use ILIAS\Data\Factory as DataFactory; use ILIAS\Language\Language; -use ILIAS\Setup\Objective\NullObjective; -use ILIAS\Refinery\Transformation; use PHPUnit\Framework\TestCase; class ImplementationOfAgentFinderTest extends TestCase { - private function finder(array $component_agents): ImplementationOfAgentFinder + private string $plugin_directory; + + protected function setUp(): void { + $this->plugin_directory = sys_get_temp_dir() . "/ilias_agent_finder_" . bin2hex(random_bytes(4)) + . "/Customizing/global/plugins"; + } + + protected function tearDown(): void + { + $root = dirname($this->plugin_directory, 3); + if (!is_dir($root)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); + } + + private function finder( + array $component_agents, + ?ImplementationOfInterfaceFinder $interface_finder = null + ): ImplementationOfAgentFinder { $refinery = new Refinery(new DataFactory(), $this->createStub(Language::class)); return new ImplementationOfAgentFinder( $refinery, new DataFactory(), $this->createStub(Language::class), - $this->createStub(ImplementationOfInterfaceFinder::class), - $component_agents + $interface_finder ?? $this->createStub(ImplementationOfInterfaceFinder::class), + $component_agents, + $this->plugin_directory ); } - public function testNamedAgentIsKeyedByItsDeclaredName(): void + private function createPlugin(string $path, string ...$agent_classes): void { - $named = $this->createStub(NamedAgent::class); - $named->method('getAgentName')->willReturn('content_isolation'); - - $collection = $this->finder([$named])->getComponentAgents(); - - $this->assertSame($named, $collection->getAgent('content_isolation')); - } - - public function testPlainAgentFallsBackToClassNameKeying(): void - { - $plain = $this->createStub(Agent::class); - - $collection = $this->finder([$plain])->getComponentAgents(); - - // no semantic name -> not reachable under one, but reachable by class name - $this->assertNull($collection->getAgent('content_isolation')); - $this->assertSame($plain, $collection->getAgent($plain::class)); - } - - public function testPluginWithoutAgentGetsTheDefaultAgent(): void - { - $agent = $this->pluginFinder([])->getPluginAgent('TestPlugin'); - - $this->assertInstanceOf(\ilPluginDefaultAgent::class, $agent); - $this->assertNotInstanceOf(\ilPluginAgentWithDefaultObjectives::class, $agent); + mkdir($this->plugin_directory . "/" . $path . "/classes", 0777, true); + foreach ($agent_classes as $class) { + $file = $this->plugin_directory . "/" . $path . "/classes/class.$class.php"; + file_put_contents($file, sprintf(<<<'PHP' + pluginFinder([TestPluginDefaultAgent::class])->getPluginAgent('TestPlugin'); - - $this->assertInstanceOf(TestPluginDefaultAgent::class, $agent); + if (class_exists(\TestPluginObjective::class, false)) { + return; + } + $file = sys_get_temp_dir() . "/ilias_agent_finder_test_objective.php"; + file_put_contents( + $file, + "pluginFinder([TestPluginAgent::class])->getPluginAgent('TestPlugin'); - - $this->assertInstanceOf(\ilPluginAgentWithDefaultObjectives::class, $agent); - - $install_objectives = $this->flatten($agent->getInstallObjective()); - $this->assertContains(\ilComponentInstallPluginObjective::class, $install_objectives); - $this->assertContains(\ilComponentActivatePluginsObjective::class, $install_objectives); - $this->assertContains(TestPluginObjective::class, $install_objectives); - - $update_objectives = $this->flatten($agent->getUpdateObjective()); - $this->assertContains(\ilComponentUpdatePluginObjective::class, $update_objectives); - $this->assertNotContains(\ilComponentActivatePluginsObjective::class, $update_objectives); - $this->assertContains(TestPluginObjective::class, $update_objectives); + $class = "il" . $plugin . "Agent" . bin2hex(random_bytes(4)); + $dir = $this->plugin_directory . "/Services/Repository/RepositoryObject/" . $plugin . "/classes"; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + $file = $dir . "/class.$class.php"; + file_put_contents($file, "pluginFinder([TestPluginAgent::class])->getPluginAgent('TestPlugin'); - - $this->assertSame(['test_objective'], array_keys($agent->getNamedObjectives())); - $this->assertSame(['test_migration'], array_keys($agent->getMigrations())); - $this->assertTrue($agent->hasConfig()); + return <<<'PHP' + class %1$s extends \ilPluginDefaultAgent + { + public function __construct( + \ILIAS\Refinery\Factory $refinery, + \ILIAS\Data\Factory $data_factory, + \ILIAS\Language\Language $lng + ) { + parent::__construct('%1$s'); + } + } + PHP; } - public function testSeveralAgentsAreCollectedUnderTheirShortClassNames(): void + /** A class implementing Setup\Agent (not extending the default agent), named %1$s. */ + private function implementingAgentTemplate(): string { - $agent = $this->pluginFinder( - [TestPluginDefaultAgent::class, TestPluginAgent::class] - )->getPluginAgent('TestPlugin'); - - $this->assertInstanceOf(AgentCollection::class, $agent); - $this->assertInstanceOf(TestPluginDefaultAgent::class, $agent->getAgent('TestPluginDefaultAgent')); - $this->assertInstanceOf(TestPluginAgent::class, $agent->getAgent('TestPluginAgent')); + return <<<'PHP' + class %1$s implements \ILIAS\Setup\Agent + { + public function __construct( + \ILIAS\Refinery\Factory $refinery, + \ILIAS\Data\Factory $data_factory, + \ILIAS\Language\Language $lng + ) { + } + + public function hasConfig(): bool + { + return true; + } + + public function getArrayToConfigTransformation(): \ILIAS\Refinery\Transformation + { + throw new \LogicException('not needed'); + } + + public function getInstallObjective(?\ILIAS\Setup\Config $config = null): \ILIAS\Setup\Objective + { + return new \TestPluginObjective(); + } + + public function getUpdateObjective(?\ILIAS\Setup\Config $config = null): \ILIAS\Setup\Objective + { + return new \TestPluginObjective(); + } + + public function getBuildObjective(): \ILIAS\Setup\Objective + { + return new \ILIAS\Setup\Objective\NullObjective(); + } + + public function getStatusObjective(\ILIAS\Setup\Metrics\Storage $storage): \ILIAS\Setup\Objective + { + return new \ILIAS\Setup\Objective\NullObjective(); + } + + public function getMigrations(): array + { + return ['test_migration' => new class () implements \ILIAS\Setup\Migration { + public function getLabel(): string + { + return 'test migration'; + } + + public function getDefaultAmountOfStepsPerRun(): int + { + return 1; + } + + public function getPreconditions(\ILIAS\Setup\Environment $environment): array + { + return []; + } + + public function prepare(\ILIAS\Setup\Environment $environment): void + { + } + + public function step(\ILIAS\Setup\Environment $environment): void + { + } + + public function getRemainingAmountOfSteps(): int + { + return 0; + } + }]; + } + + public function getNamedObjectives(?\ILIAS\Setup\Config $config = null): array + { + return [ + 'test_objective' => new \ILIAS\Setup\ObjectiveConstructor( + 'test objective', + static fn(): \ILIAS\Setup\Objective => new \ILIAS\Setup\Objective\NullObjective() + ) + ]; + } + } + PHP; } private function pluginFinder(array $agent_classes): ImplementationOfAgentFinder @@ -128,13 +244,7 @@ private function pluginFinder(array $agent_classes): ImplementationOfAgentFinder ->method('getMatchingClassNames') ->willReturn(new \ArrayIterator($agent_classes)); - return new ImplementationOfAgentFinder( - $this->createStub(\ILIAS\Refinery\Factory::class), - new \ILIAS\Data\Factory(), - $this->createStub(\ILIAS\Language\Language::class), - $interface_finder, - [] - ); + return $this->finder([], $interface_finder); } /** @@ -151,107 +261,139 @@ protected function flatten(Setup\Objective $objective): array $objective->getObjectives() )); } -} -class TestPluginObjective extends NullObjective -{ -} + public function testNamedAgentIsKeyedByItsDeclaredName(): void + { + $named = $this->createStub(NamedAgent::class); + $named->method('getAgentName')->willReturn('content_isolation'); -class TestPluginAgent implements Setup\Agent -{ - public function __construct( - \ILIAS\Refinery\Factory $refinery, - \ILIAS\Data\Factory $data_factory, - \ILIAS\Language\Language $lng - ) { - } + $collection = $this->finder([$named])->getComponentAgents(); - public function hasConfig(): bool - { - return true; + $this->assertSame($named, $collection->getAgent('content_isolation')); } - public function getArrayToConfigTransformation(): Transformation + public function testPlainAgentFallsBackToClassNameKeying(): void { - throw new \LogicException('not needed'); - } + $plain = $this->createStub(Agent::class); - public function getInstallObjective(?Setup\Config $config = null): Setup\Objective - { - return new TestPluginObjective(); + $collection = $this->finder([$plain])->getComponentAgents(); + + // no semantic name -> not reachable under one, but reachable by class name + $this->assertNull($collection->getAgent('content_isolation')); + $this->assertSame($plain, $collection->getAgent($plain::class)); } - public function getUpdateObjective(?Setup\Config $config = null): Setup\Objective + public function testPluginsAreFoundFourLevelsBelowThePluginDirectory(): void { - return new TestPluginObjective(); + $this->createPlugin("Services/Repository/RepositoryObject/Alpha"); + $this->createPlugin("Modules/Course/CourseHook/Beta"); + mkdir($this->plugin_directory . "/Services/Repository/RepositoryObject/.hidden", 0777, true); + touch($this->plugin_directory . "/Services/Repository/RepositoryObject/README.md"); + + $agents = $this->finder([])->getAgents(); + + $this->assertEqualsCanonicalizing(['Alpha', 'Beta'], array_keys($agents->getAgents())); + $this->assertInstanceOf(\ilPluginDefaultAgent::class, $agents->getAgent('Alpha')); } - public function getBuildObjective(): Setup\Objective + public function testAgentsOfAllPluginsAreSearchedInOnePass(): void { - return new NullObjective(); + $suffix = bin2hex(random_bytes(4)); + $gamma = "ilGammaSetupAgent$suffix"; + $delta_1 = "ilDeltaOneSetupAgent$suffix"; + $delta_2 = "ilDeltaTwoSetupAgent$suffix"; + $this->createPlugin("Services/Repository/RepositoryObject/Gamma", $gamma); + $this->createPlugin("Services/Repository/RepositoryObject/Delta", $delta_1, $delta_2); + $this->createPlugin("Services/Repository/RepositoryObject/Epsilon"); + + $interface_finder = $this->createMock(ImplementationOfInterfaceFinder::class); + $interface_finder + ->expects($this->once()) + ->method('getMatchingClassNames') + ->willReturn(new \ArrayIterator([$gamma, $delta_1, $delta_2])); + + $agents = $this->finder([], $interface_finder)->getAgents(); + + $this->assertInstanceOf($gamma, $agents->getAgent('Gamma')); + $this->assertInstanceOf(AgentCollection::class, $agents->getAgent('Delta')); + $this->assertCount(2, $agents->getAgent('Delta')->getAgents()); + $this->assertInstanceOf(\ilPluginDefaultAgent::class, $agents->getAgent('Epsilon')); } - public function getStatusObjective(Setup\Metrics\Storage $storage): Setup\Objective + public function testPluginAgentIgnoresClassesOfOtherPlugins(): void { - return new NullObjective(); + $suffix = bin2hex(random_bytes(4)); + $zeta = "ilZetaSetupAgent$suffix"; + // a plugin that happens to contain a directory named like another plugin + $this->createPlugin("Services/Repository/RepositoryObject/Eta/lib/Zeta", $zeta); + + $interface_finder = $this->createStub(ImplementationOfInterfaceFinder::class); + $interface_finder->method('getMatchingClassNames')->willReturn(new \ArrayIterator([$zeta])); + + $agent = $this->finder([], $interface_finder)->getPluginAgent('Zeta'); + + $this->assertNotInstanceOf($zeta, $agent); + $this->assertInstanceOf(\ilPluginDefaultAgent::class, $agent); } - public function getMigrations(): array + public function testPluginWithoutAgentGetsTheDefaultAgent(): void { - return ['test_migration' => $this->createStub()]; + $agent = $this->pluginFinder([])->getPluginAgent('TestPlugin'); + + $this->assertInstanceOf(\ilPluginDefaultAgent::class, $agent); + $this->assertNotInstanceOf(\ilPluginAgentWithDefaultObjectives::class, $agent); } - public function getNamedObjectives(?Setup\Config $config = null): array + public function testAgentExtendingTheDefaultAgentIsUsedDirectly(): void { - return [ - 'test_objective' => new Setup\ObjectiveConstructor( - 'test objective', - static fn(): Setup\Objective => new NullObjective() - ) - ]; + $class = $this->writePluginAgent('TestPlugin', $this->defaultAgentTemplate()); + + $agent = $this->pluginFinder([$class])->getPluginAgent('TestPlugin'); + + $this->assertInstanceOf($class, $agent); } - private function createStub(): Setup\Migration + public function testAgentImplementingAgentGetsTheDefaultObjectives(): void { - return new class () implements Setup\Migration { - public function getLabel(): string - { - return 'test migration'; - } + $this->ensureTestObjectiveClass(); + $class = $this->writePluginAgent('TestPlugin', $this->implementingAgentTemplate()); - public function getDefaultAmountOfStepsPerRun(): int - { - return 1; - } + $agent = $this->pluginFinder([$class])->getPluginAgent('TestPlugin'); - public function getPreconditions(Setup\Environment $environment): array - { - return []; - } + $this->assertInstanceOf(\ilPluginAgentWithDefaultObjectives::class, $agent); - public function prepare(Setup\Environment $environment): void - { - } + $install_objectives = $this->flatten($agent->getInstallObjective()); + $this->assertContains(\ilComponentInstallPluginObjective::class, $install_objectives); + $this->assertContains(\ilComponentActivatePluginsObjective::class, $install_objectives); + $this->assertContains(\TestPluginObjective::class, $install_objectives); - public function step(Setup\Environment $environment): void - { - } + $update_objectives = $this->flatten($agent->getUpdateObjective()); + $this->assertContains(\ilComponentUpdatePluginObjective::class, $update_objectives); + $this->assertNotContains(\ilComponentActivatePluginsObjective::class, $update_objectives); + $this->assertContains(\TestPluginObjective::class, $update_objectives); + } - public function getRemainingAmountOfSteps(): int - { - return 0; - } - }; + public function testAgentImplementingAgentKeepsItsPaths(): void + { + $this->ensureTestObjectiveClass(); + $class = $this->writePluginAgent('TestPlugin', $this->implementingAgentTemplate()); + + $agent = $this->pluginFinder([$class])->getPluginAgent('TestPlugin'); + + $this->assertSame(['test_objective'], array_keys($agent->getNamedObjectives())); + $this->assertSame(['test_migration'], array_keys($agent->getMigrations())); + $this->assertTrue($agent->hasConfig()); } -} -class TestPluginDefaultAgent extends \ilPluginDefaultAgent -{ - public function __construct( - \ILIAS\Refinery\Factory $refinery, - \ILIAS\Data\Factory $data_factory, - \ILIAS\Language\Language $lng - ) { - parent::__construct('TestPlugin'); + public function testSeveralAgentsAreCollectedUnderTheirShortClassNames(): void + { + $default_agent = $this->writePluginAgent('TestPlugin', $this->defaultAgentTemplate()); + $implementing_agent = $this->writePluginAgent('TestPlugin', $this->implementingAgentTemplate()); + + $agent = $this->pluginFinder([$default_agent, $implementing_agent])->getPluginAgent('TestPlugin'); + + $this->assertInstanceOf(AgentCollection::class, $agent); + $this->assertInstanceOf($default_agent, $agent->getAgent($default_agent)); + $this->assertInstanceOf($implementing_agent, $agent->getAgent($implementing_agent)); } }