From 3904939afaaeb0f06611e7dbeae318ac9919e6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Barto=C5=A1?= Date: Thu, 14 Mar 2024 13:38:11 +0100 Subject: [PATCH] SymfonyCommandJob --- CHANGELOG.md | 1 + composer.json | 1 + docs/README.md | 31 +++- src/Job/SymfonyCommandJob.php | 109 +++++++++++ tests/Doubles/TestExceptionCommand.php | 35 ++++ tests/Doubles/TestFailNoOutputCommand.php | 22 +++ tests/Doubles/TestFailOutputCommand.php | 25 +++ tests/Doubles/TestParametrizedCommand.php | 27 +++ tests/Doubles/TestSuccessCommand.php | 24 +++ tests/Helpers/CommandOutputHelper.php | 11 +- tests/Unit/Job/SymfonyCommandJobTest.php | 210 ++++++++++++++++++++++ 11 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 src/Job/SymfonyCommandJob.php create mode 100644 tests/Doubles/TestExceptionCommand.php create mode 100644 tests/Doubles/TestFailNoOutputCommand.php create mode 100644 tests/Doubles/TestFailOutputCommand.php create mode 100644 tests/Doubles/TestParametrizedCommand.php create mode 100644 tests/Doubles/TestSuccessCommand.php create mode 100644 tests/Unit/Job/SymfonyCommandJobTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8653f..a6901d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - explains cron expression syntax - `ListCommand` - adds `--explain` option to explain whole expression +- `SymfonyCommandJob` ### Fixed diff --git a/composer.json b/composer.json index f51c7b7..818e547 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "orisai/exceptions": "^1.1.0", "symfony/console": "^5.3.0|^6.0.0", "symfony/lock": "^5.3.0|^6.0.0", + "symfony/polyfill-php80": "^1.29.0", "symfony/process": "^5.3.0|^6.0.0" }, "require-dev": { diff --git a/docs/README.md b/docs/README.md index 0dd683f..e9cdccd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -514,6 +514,35 @@ $scheduler->addJob( ); ``` +### Symfony command job + +Run [symfony/console](https://github.com/symfony/console) command as a job + +- if job succeeds (returns zero code), command output is ignored +- if job fails (returns non-zero code), exception is thrown, including command return code, output and if thrown by the + command, the exception + +```php +use Orisai\Scheduler\Job\SymfonyCommandJob; + +$job = new SymfonyCommandJob($command, $application); +$scheduler->addJob( + $job, + /* ... */, +); + +``` + +Command can be parametrized: + +```php +$job->setCommandParameters([ + 'argument' => 'value', + '--option' => 'value', + '--boolean-option' => true, +]); +``` + ## Job info and result Status information available via [events](#events) and [run summary](#run-summary) @@ -582,7 +611,7 @@ thrown `JobFailure`. ## CLI commands -For symfony/console you may use our commands: +For [symfony/console](https://github.com/symfony/console) you may use our commands: - [Run](#run-command) - [Run job](#run-job-command) diff --git a/src/Job/SymfonyCommandJob.php b/src/Job/SymfonyCommandJob.php new file mode 100644 index 0000000..36183b8 --- /dev/null +++ b/src/Job/SymfonyCommandJob.php @@ -0,0 +1,109 @@ + */ + private array $parameters = []; + + public function __construct(Command $command, Application $application) + { + $this->command = $command; + $this->application = $application; + } + + /** + * @param array $parameters + */ + public function setCommandParameters(array $parameters): void + { + $this->parameters = $parameters; + } + + public function getName(): string + { + $name = $this->command->getName(); + assert($name !== null); // It must be set in constructor + + return 'symfony/console: ' . $this->createInput(); + } + + public function run(JobLock $lock): void + { + $input = $this->createInput(); + $output = new BufferedOutput(); + + try { + // Using doRun() to prevent auto-exiting and error-handling + $exitCode = $this->application->doRun($input, $output); + } catch (Throwable $commandException) { + $exitCode = $this->getExceptionCode($commandException); + } + + $outputString = $output->fetch(); + + if ($exitCode !== 0) { + $message = Message::create() + ->withContext("Running command '{$this->command->getName()}'.") + ->withProblem("Run failed with code '$exitCode'."); + + if ($outputString !== '') { + $message->with('Output', $outputString); + } + + $exception = InvalidState::create() + ->withCode($exitCode) + ->withMessage($message); + + if (isset($commandException)) { + $exception->withPrevious($commandException) + ->withSuppressed([$commandException]); + } + + throw $exception; + } + } + + private function createInput(): ArrayInput + { + return new ArrayInput(array_merge( + [$this->command->getName()], + $this->parameters, + )); + } + + private function getExceptionCode(Throwable $exception): int + { + $exitCode = $exception->getCode(); + + if (is_numeric($exitCode)) { + $exitCode = (int) $exitCode; + if ($exitCode <= 0) { + $exitCode = 1; + } + + return $exitCode; + } + + /** @codeCoverageIgnore Hard to simulate, only php extensions can return non-int code */ + return 1; + } + +} diff --git a/tests/Doubles/TestExceptionCommand.php b/tests/Doubles/TestExceptionCommand.php new file mode 100644 index 0000000..5593adf --- /dev/null +++ b/tests/Doubles/TestExceptionCommand.php @@ -0,0 +1,35 @@ +code = $code; + } + + protected function configure(): void + { + $this->setName('test:exception'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Failure!'); + + throw NotImplemented::create() + ->withMessage('Message') + ->withCode($this->code); + } + +} diff --git a/tests/Doubles/TestFailNoOutputCommand.php b/tests/Doubles/TestFailNoOutputCommand.php new file mode 100644 index 0000000..ca6b2b7 --- /dev/null +++ b/tests/Doubles/TestFailNoOutputCommand.php @@ -0,0 +1,22 @@ +setName('test:fail-no-output'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + return 1; + } + +} diff --git a/tests/Doubles/TestFailOutputCommand.php b/tests/Doubles/TestFailOutputCommand.php new file mode 100644 index 0000000..68e7347 --- /dev/null +++ b/tests/Doubles/TestFailOutputCommand.php @@ -0,0 +1,25 @@ +setName('test:fail-output'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Failure!'); + $output->writeln('New line!'); + + return 256; + } + +} diff --git a/tests/Doubles/TestParametrizedCommand.php b/tests/Doubles/TestParametrizedCommand.php new file mode 100644 index 0000000..ab9eac9 --- /dev/null +++ b/tests/Doubles/TestParametrizedCommand.php @@ -0,0 +1,27 @@ +setName('test:parameters'); + $this->addArgument('argument', InputArgument::REQUIRED); + $this->addOption('option', null, InputOption::VALUE_REQUIRED); + $this->addOption('bool-option', null, InputOption::VALUE_NONE); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + return 0; + } + +} diff --git a/tests/Doubles/TestSuccessCommand.php b/tests/Doubles/TestSuccessCommand.php new file mode 100644 index 0000000..42f2546 --- /dev/null +++ b/tests/Doubles/TestSuccessCommand.php @@ -0,0 +1,24 @@ +setName('test:success'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Success!'); + + return 0; + } + +} diff --git a/tests/Helpers/CommandOutputHelper.php b/tests/Helpers/CommandOutputHelper.php index 515c0e0..5750693 100644 --- a/tests/Helpers/CommandOutputHelper.php +++ b/tests/Helpers/CommandOutputHelper.php @@ -14,9 +14,16 @@ final class CommandOutputHelper { - public static function getCommandOutput(CommandTester $tester): string + /** + * @param string|CommandTester $output + */ + public static function getCommandOutput($output): string { - $display = preg_replace('~\R~u', PHP_EOL, $tester->getDisplay()); + if ($output instanceof CommandTester) { + $output = $output->getDisplay(); + } + + $display = preg_replace('~\R~u', PHP_EOL, $output); assert($display !== null); return implode( diff --git a/tests/Unit/Job/SymfonyCommandJobTest.php b/tests/Unit/Job/SymfonyCommandJobTest.php new file mode 100644 index 0000000..d4d98b4 --- /dev/null +++ b/tests/Unit/Job/SymfonyCommandJobTest.php @@ -0,0 +1,210 @@ +add($command); + $job = new SymfonyCommandJob($command, $application); + + self::assertStringMatchesFormat('symfony/console: %ctest:success%c', $job->getName()); + + // No output, no need to assert + $job->run(new JobLock(new NoLock())); + } + + public function testFailNoOutput(): void + { + $command = new TestFailNoOutputCommand(); + $application = new Application(); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + + self::assertStringMatchesFormat('symfony/console: %ctest:fail-no-output%c', $job->getName()); + + $e = null; + try { + $job->run(new JobLock(new NoLock())); + } catch (InvalidState $e) { + // Handled bellow + } + + self::assertNotNull($e); + self::assertSame(1, $e->getCode()); + self::assertSame( + <<<'MSG' +Context: Running command 'test:fail-no-output'. +Problem: Run failed with code '1'. +MSG, + $e->getMessage(), + ); + self::assertNull($e->getPrevious()); + } + + public function testFailOutput(): void + { + $command = new TestFailOutputCommand(); + $application = new Application(); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + + self::assertStringMatchesFormat('symfony/console: %ctest:fail-output%c', $job->getName()); + + $e = null; + try { + $job->run(new JobLock(new NoLock())); + } catch (InvalidState $e) { + // Handled bellow + } + + self::assertNotNull($e); + self::assertSame(256, $e->getCode()); + self::assertSame( + <<<'MSG' +Context: Running command 'test:fail-output'. +Problem: Run failed with code '256'. +Output: Failure! + New line! + +MSG, + CommandOutputHelper::getCommandOutput($e->getMessage()), + ); + self::assertNull($e->getPrevious()); + } + + /** + * @dataProvider provideException + */ + public function testException(int $exceptionCode, int $commandCode): void + { + $command = new TestExceptionCommand($exceptionCode); + $application = new Application(); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + + self::assertStringMatchesFormat('symfony/console: %ctest:exception%c', $job->getName()); + + $e = null; + try { + $job->run(new JobLock(new NoLock())); + } catch (InvalidState $e) { + // Handled bellow + } + + self::assertNotNull($e); + self::assertSame($commandCode, $e->getCode()); + self::assertStringMatchesFormat( + <<getMessage()), + ); + self::assertInstanceOf(NotImplemented::class, $e->getPrevious()); + } + + public function provideException(): Generator + { + yield [0, 1]; + yield [-1, 1]; + yield [2, 2]; + yield [256, 256]; + } + + /** + * @dataProvider provideApplicationSettingsHaveNoEffect + */ + public function testApplicationSettingsHaveNoEffect(bool $autoExit, bool $catchExceptions): void + { + $command = new TestExceptionCommand(1); + $application = new Application(); + $application->setAutoExit($autoExit); + $application->setCatchExceptions($catchExceptions); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + + self::assertStringMatchesFormat('symfony/console: %ctest:exception%c', $job->getName()); + + $e = null; + try { + $job->run(new JobLock(new NoLock())); + } catch (InvalidState $e) { + // Handled bellow + } + + self::assertNotNull($e); + self::assertSame(1, $e->getCode()); + self::assertInstanceOf(NotImplemented::class, $e->getPrevious()); + } + + public function provideApplicationSettingsHaveNoEffect(): Generator + { + yield [true, true]; + yield [false, false]; + yield [true, false]; + yield [false, true]; + } + + public function testCommandNameCannotBeChanged(): void + { + $command = new TestSuccessCommand(); + $application = new Application(); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + // Is ignored + $job->setCommandParameters(['command' => 'non-existent']); + + // No output, no need to assert + $job->run(new JobLock(new NoLock())); + + /** @phpstan-ignore-next-line */ + self::assertTrue(true); + } + + public function testCommandParameters(): void + { + $command = new TestParametrizedCommand(); + $application = new Application(); + $application->add($command); + $job = new SymfonyCommandJob($command, $application); + $job->setCommandParameters([ + 'argument' => 'a', + '--option' => 'b', + '--bool-option' => true, + ]); + + self::assertStringMatchesFormat( + 'symfony/console: %ctest:parameters%c a --option=b --bool-option=1', + $job->getName(), + ); + + // No output, no need to assert + $job->run(new JobLock(new NoLock())); + } + +}