From 38249e6b5e2b9981ba83d7ce3a350de93bac8ded Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Fri, 18 Sep 2020 01:10:48 +0200 Subject: [PATCH] base plugin installation command (#1953) --- .../AbstractBundleInstallerCommand.php | 195 +++++++++++++++++ tests/Command/BundleInstallerCommandTest.php | 197 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100755 src/Command/AbstractBundleInstallerCommand.php create mode 100644 tests/Command/BundleInstallerCommandTest.php diff --git a/src/Command/AbstractBundleInstallerCommand.php b/src/Command/AbstractBundleInstallerCommand.php new file mode 100755 index 00000000..df8e1f7b --- /dev/null +++ b/src/Command/AbstractBundleInstallerCommand.php @@ -0,0 +1,195 @@ +getApplication(); + + return $application->getKernel()->getProjectDir(); + } + + /** + * If your bundle ships assets, that need to be available in the public/ directory, + * then overwrite this method and return: . + * + * @return bool + */ + protected function hasAssets(): bool + { + return false; + } + + /** + * Returns an absolute filename to your doctrine migrations configuration, if you want to install database tables. + * + * @return string|null + */ + protected function getMigrationConfigFilename(): ?string + { + return null; + } + + /** + * Returns the bundle short name for the installer command. + * + * @return string + */ + abstract protected function getBundleCommandNamePart(): string; + + /** + * Returns the full name fo this command. + * Please stick to the standard and overwrite getBundleCommandNamePart() only. + * + * @return string + */ + protected function getInstallerCommandName(): string + { + return sprintf('kimai:bundle:%s:install', $this->getBundleCommandNamePart()); + } + + /** + * Returns the bundles real name (same as your namespace). + * + * @return string + */ + protected function getBundleName(): string + { + $class = new \ReflectionClass($this); + $parts = explode('\\', $class->getNamespaceName()); + + if ($parts[0] !== 'KimaiPlugin') { + throw new LogicException( + sprintf('Unsupported namespace given, expected "KimaiPlugin" but received "%s". Please overwrite getBundleName() and return the correct bundle name.', $parts[0]) + ); + } + + return $parts[1]; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName($this->getInstallerCommandName()) + ->setDescription('Install the bundle: ' . $this->getBundleName()) + ->setHelp('This command will perform the basic installation steps to get the bundle up and running.') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + // many users execute the bin/console command from arbitrary locations + // this will make sure that relative paths (like doctrine migrations) work as expected + $path = getcwd(); + chdir($this->getRootDirectory()); + + $bundleName = $this->getBundleName(); + $io->title( + sprintf('Starting installation of plugin: %s ...', $bundleName) + ); + + try { + $this->importMigrations($io, $output); + } catch (\Exception $ex) { + $io->error( + sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage()) + ); + + return 1; + } + + if ($this->hasAssets()) { + try { + $this->installAssets($io, $output); + } catch (\Exception $ex) { + $io->error( + sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage()) + ); + + return 1; + } + } + + chdir($path); + + $io->success( + sprintf('Congratulations! Plugin was successful installed: %s', $bundleName) + ); + + return 0; + } + + protected function installAssets(SymfonyStyle $io, OutputInterface $output) + { + $command = $this->getApplication()->find('assets:install'); + $cmdInput = new ArrayInput([]); + $cmdInput->setInteractive(false); + if (0 !== $command->run($cmdInput, $output)) { + throw new \Exception('Problem occurred while installing assets.'); + } + + $io->writeln(''); + } + + protected function importMigrations(SymfonyStyle $io, OutputInterface $output) + { + $config = $this->getMigrationConfigFilename(); + + if (null === $config) { + return false; + } + + if (!file_exists($config)) { + throw new FileNotFoundException('Missing doctrine migrations config file: ' . $config); + } + + // prevent windows from breaking + $config = str_replace('/', DIRECTORY_SEPARATOR, $config); + + $command = $this->getApplication()->find('doctrine:migrations:migrate'); + $cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]); + $cmdInput->setInteractive(false); + if (0 !== $command->run($cmdInput, $output)) { + throw new \Exception('Problem occurred while executing migrations.'); + } + + $io->writeln(''); + } +} diff --git a/tests/Command/BundleInstallerCommandTest.php b/tests/Command/BundleInstallerCommandTest.php new file mode 100644 index 00000000..1cbdfdc2 --- /dev/null +++ b/tests/Command/BundleInstallerCommandTest.php @@ -0,0 +1,197 @@ +application = new Application($kernel); + $container = self::$kernel->getContainer(); + + $this->application->add(new $className()); + + return $this->application->find('kimai:bundle:test:install'); + } + + public function testFullRun() + { + $command = $this->getCommand(TestBundleInstallerCommand::class); + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('Starting installation of plugin: Test', $result); + self::assertStringContainsString('[OK] Congratulations! Plugin was successful installed: Test', $result); + self::assertEquals(0, $commandTester->getStatusCode()); + } + + public function testMissingMigrationThrowsException() + { + $command = $this->getCommand(InstallerWithMissingMigrationsCommand::class); + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('[ERROR] Failed to install database for bundle TestBundle.', $result); + self::assertEquals(1, $commandTester->getStatusCode()); + } + + public function testAssetsInstallationFailure() + { + $command = $this->getCommand(AssetsInstallerFailureCommand::class); + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('[ERROR] Failed to install assets for bundle TestBundle.', $result); + self::assertEquals(1, $commandTester->getStatusCode()); + } + + public function testInvalidNamespaceWillRaiseException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unsupported namespace given, expected "KimaiPlugin" but received "App". Please overwrite getBundleName() and return the correct bundle name.'); + + $command = $this->getCommand(InvalidNamespaceTestBundleInstallerCommand::class); + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + } + + public function testAssetsInstallIsOk() + { + $command = $this->getCommand(InstallerWithAssetsCommand::class); + + $this->application->add(new FakeCommand('assets:install', 0, null)); + + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('Command assets:install was executed successfully :-)', $result); + self::assertEquals(0, $commandTester->getStatusCode()); + } + + public function testAssetsInstallReturnsNonZeroExitCode() + { + $command = $this->getCommand(InstallerWithAssetsCommand::class); + + $this->application->add(new FakeCommand('assets:install', 1, null)); + + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName()]); + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('[ERROR] Failed to install assets for bundle TestBundle.', $result); + self::assertEquals(1, $commandTester->getStatusCode()); + } +} + +class FakeCommand extends Command +{ + /** + * @var null|string + */ + private $exception = null; + /** + * @var int + */ + private $exitCode = 0; + + public function __construct(string $commandName, int $exitCode, ?string $executeThrows = null) + { + parent::__construct($commandName); + $this->exitCode = $exitCode; + $this->exception = $executeThrows; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if (null !== $this->exception) { + throw new \Exception($this->exception); + } + + if ($this->exitCode === 0) { + $output->write('Command ' . $this->getName() . ' was executed successfully :-)'); + } + + return $this->exitCode; + } +} + +class InvalidNamespaceTestBundleInstallerCommand extends AbstractBundleInstallerCommand +{ + protected function getBundleCommandNamePart(): string + { + return 'test'; + } +} + +class TestBundleInstallerCommand extends AbstractBundleInstallerCommand +{ + protected function getBundleCommandNamePart(): string + { + return 'test'; + } + + protected function getBundleName(): string + { + return 'TestBundle'; + } +} + +class InstallerWithMissingMigrationsCommand extends TestBundleInstallerCommand +{ + protected function getMigrationConfigFilename(): ?string + { + return __DIR__ . '/sdfsdfsdfsdf'; + } +} + +class AssetsInstallerFailureCommand extends TestBundleInstallerCommand +{ + protected function hasAssets(): bool + { + return true; + } + + protected function installAssets(SymfonyStyle $io, OutputInterface $output) + { + throw new \Exception('Problem occurred while installing assets.'); + } +} + +class InstallerWithAssetsCommand extends TestBundleInstallerCommand +{ + protected function hasAssets(): bool + { + return true; + } +}