/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php
701 строка
28 KB
Dawid Nowak
[Console] Allow callable for the `description` and `help` options of `#[AsCommand]`, `#[Argument]` and `#[Option]`
час назад
час назад
a6a5fa7
Код
Авторство
О чём код?
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\ArgumentResolver\ArgumentResolver; use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\Ask; use Symfony\Component\Console\Attribute\MapDateTime; use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Attribute\Reflection\ReflectionMember; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Cursor; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; use Symfony\Component\Console\Tests\Fixtures\InvokableWithCustomValidatorTestCommand; use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputFileAndConstraintsTestCommand; class InvokableCommandTest extends TestCase { public function testCommandInputArgumentDefinition() { $command = new Command('foo'); $command->setCode(function ( #[Argument(name: 'very-first-name')] string $name, #[Argument] ?string $firstName, #[Argument] string $lastName = '', #[Argument(description: 'Short argument description')] string $bio = '', // In this test case, we declare the callback in static context, even when the method is NOT static. // PHP doesn't allow using `$this` here, and the callback is later modified on-the-fly // to be called on the instance instead, and this test case validates if this mechanism works. // // @see \Symfony\Component\Console\Attribute\Argument #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], ): int { \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied return 0; }); $nameInputArgument = $command->getDefinition()->getArgument('very-first-name'); self::assertSame('very-first-name', $nameInputArgument->getName()); self::assertTrue($nameInputArgument->isRequired()); $lastNameInputArgument = $command->getDefinition()->getArgument('first-name'); self::assertSame('first-name', $lastNameInputArgument->getName()); self::assertFalse($lastNameInputArgument->isRequired()); self::assertNull($lastNameInputArgument->getDefault()); $lastNameInputArgument = $command->getDefinition()->getArgument('last-name'); self::assertSame('last-name', $lastNameInputArgument->getName()); self::assertFalse($lastNameInputArgument->isRequired()); self::assertSame('', $lastNameInputArgument->getDefault()); $bioInputArgument = $command->getDefinition()->getArgument('bio'); self::assertSame('bio', $bioInputArgument->getName()); self::assertFalse($bioInputArgument->isRequired()); self::assertSame('Short argument description', $bioInputArgument->getDescription()); self::assertSame('', $bioInputArgument->getDefault()); $rolesInputArgument = $command->getDefinition()->getArgument('roles'); self::assertSame('roles', $rolesInputArgument->getName()); self::assertFalse($rolesInputArgument->isRequired()); self::assertTrue($rolesInputArgument->isArray()); self::assertSame(['ROLE_USER'], $rolesInputArgument->getDefault()); self::assertTrue($rolesInputArgument->hasCompletion()); $rolesInputArgument->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); } public function testCommandInputDescriptionFromCallable() { $command = new Command('foo'); $command->setCode(static function ( #[Argument(description: [self::class, 'generateBioDescription'])] string $bio = '', #[Option(description: [self::class, 'generateBioDescription'])] string $summary = '', ): int { return 0; }); self::assertSame('Generated bio description', $command->getDefinition()->getArgument('bio')->getDescription()); self::assertSame('Generated bio description', $command->getDefinition()->getOption('summary')->getDescription()); } public function testCommandInputDescriptionIsNeverResolvedAsCallable() { $command = new Command('foo'); $command->setCode(static function ( // "define" is the name of a PHP function, it must still be taken as a plain description #[Argument(description: 'define')] string $bio = '', #[Option(description: 'define')] string $summary = '', ): int { return 0; }); self::assertSame('define', $command->getDefinition()->getArgument('bio')->getDescription()); self::assertSame('define', $command->getDefinition()->getOption('summary')->getDescription()); } public function testCommandInputOptionDefinition() { $command = new Command('foo'); $command->setCode(function ( #[Option(name: 'idle')] ?int $timeout = null, #[Option] string $type = 'USER_TYPE', #[Option(shortcut: 'v')] bool $verbose = false, #[Option(description: 'User groups')] array $groups = [], #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], #[Option] string|bool $opt = false, #[Option(deprecated: true)] bool $old = false, #[Option(hidden: true)] ?string $secret = null, ): int { \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied return 0; }); $timeoutInputOption = $command->getDefinition()->getOption('idle'); self::assertSame('idle', $timeoutInputOption->getName()); self::assertNull($timeoutInputOption->getShortcut()); self::assertTrue($timeoutInputOption->isValueRequired()); self::assertFalse($timeoutInputOption->isValueOptional()); self::assertFalse($timeoutInputOption->isNegatable()); self::assertNull($timeoutInputOption->getDefault()); $typeInputOption = $command->getDefinition()->getOption('type'); self::assertSame('type', $typeInputOption->getName()); self::assertTrue($typeInputOption->isValueRequired()); self::assertFalse($typeInputOption->isNegatable()); self::assertSame('USER_TYPE', $typeInputOption->getDefault()); $verboseInputOption = $command->getDefinition()->getOption('verbose'); self::assertSame('verbose', $verboseInputOption->getName()); self::assertSame('v', $verboseInputOption->getShortcut()); self::assertFalse($verboseInputOption->isValueRequired()); self::assertFalse($verboseInputOption->isValueOptional()); self::assertFalse($verboseInputOption->isNegatable()); self::assertFalse($verboseInputOption->getDefault()); $groupsInputOption = $command->getDefinition()->getOption('groups'); self::assertSame('groups', $groupsInputOption->getName()); self::assertTrue($groupsInputOption->isArray()); self::assertSame('User groups', $groupsInputOption->getDescription()); self::assertFalse($groupsInputOption->isNegatable()); self::assertSame([], $groupsInputOption->getDefault()); $rolesInputOption = $command->getDefinition()->getOption('roles'); self::assertSame('roles', $rolesInputOption->getName()); self::assertTrue($rolesInputOption->isValueRequired()); self::assertFalse($rolesInputOption->isNegatable()); self::assertTrue($rolesInputOption->isArray()); self::assertSame(['ROLE_USER'], $rolesInputOption->getDefault()); self::assertTrue($rolesInputOption->hasCompletion()); $rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); $optInputOption = $command->getDefinition()->getOption('opt'); self::assertSame('opt', $optInputOption->getName()); self::assertNull($optInputOption->getShortcut()); self::assertFalse($optInputOption->isValueRequired()); self::assertTrue($optInputOption->isValueOptional()); self::assertFalse($optInputOption->isNegatable()); self::assertFalse($optInputOption->getDefault()); $oldInputOption = $command->getDefinition()->getOption('old'); self::assertTrue($oldInputOption->isDeprecated()); self::assertFalse($oldInputOption->isHidden()); $secretInputOption = $command->getDefinition()->getOption('secret'); self::assertFalse($secretInputOption->isDeprecated()); self::assertTrue($secretInputOption->isHidden()); } public function testEnumArgument() { $command = new Command('foo'); $command->setCode(static function ( #[Argument] StringEnum $enum, #[Argument] StringEnum $enumWithDefault = StringEnum::Image, #[Argument] ?StringEnum $nullableEnum = null, ): int { Assert::assertSame(StringEnum::Image, $enum); Assert::assertSame(StringEnum::Image, $enumWithDefault); Assert::assertNull($nullableEnum); return 0; }); $enumInputArgument = $command->getDefinition()->getArgument('enum'); self::assertTrue($enumInputArgument->isRequired()); self::assertNull($enumInputArgument->getDefault()); self::assertTrue($enumInputArgument->hasCompletion()); $enumWithDefaultInputArgument = $command->getDefinition()->getArgument('enum-with-default'); self::assertFalse($enumWithDefaultInputArgument->isRequired()); self::assertSame('image', $enumWithDefaultInputArgument->getDefault()); self::assertTrue($enumWithDefaultInputArgument->hasCompletion()); $nullableEnumInputArgument = $command->getDefinition()->getArgument('nullable-enum'); self::assertFalse($nullableEnumInputArgument->isRequired()); self::assertNull($nullableEnumInputArgument->getDefault()); self::assertTrue($nullableEnumInputArgument->hasCompletion()); $enumInputArgument->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions()); self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions()); $command->run(new ArrayInput(['enum' => 'image']), new NullOutput()); self::expectException(InvalidArgumentException::class); self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" argument. Supported values are "image", "video".'); $command->run(new ArrayInput(['enum' => 'incorrect']), new NullOutput()); } public function testEnumOption() { $command = new Command('foo'); $command->setCode(static function ( #[Option] StringEnum $enum = StringEnum::Video, #[Option] StringEnum $enumWithDefault = StringEnum::Image, #[Option] ?StringEnum $nullableEnum = null, ): int { Assert::assertSame(StringEnum::Image, $enum); Assert::assertSame(StringEnum::Image, $enumWithDefault); Assert::assertNull($nullableEnum); return 0; }); $enumInputOption = $command->getDefinition()->getOption('enum'); self::assertTrue($enumInputOption->isValueRequired()); self::assertSame('video', $enumInputOption->getDefault()); self::assertTrue($enumInputOption->hasCompletion()); $enumWithDefaultInputOption = $command->getDefinition()->getOption('enum-with-default'); self::assertTrue($enumWithDefaultInputOption->isValueRequired()); self::assertSame('image', $enumWithDefaultInputOption->getDefault()); self::assertTrue($enumWithDefaultInputOption->hasCompletion()); $nullableEnumInputOption = $command->getDefinition()->getOption('nullable-enum'); self::assertTrue($nullableEnumInputOption->isValueRequired()); self::assertNull($nullableEnumInputOption->getDefault()); self::assertTrue($nullableEnumInputOption->hasCompletion()); $enumInputOption->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions()); self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions()); $command->run(new ArrayInput(['--enum' => 'image']), new NullOutput()); self::expectException(InvalidOptionException::class); self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" option. Supported values are "image", "video".'); $command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput()); } public function testAskDefaultIsRejectedForArrayArgument() { $command = new Command('foo'); $command->setCode(static function (#[Argument] #[Ask('Add a value', default: 0)] array $values) {}); $this->expectException(LogicException::class); $this->expectExceptionMessage('The "Symfony\Component\Console\Attribute\Ask::$default" value is not supported for the array "$values"'); $command->getDefinition(); } public function testExecuteHasPriorityOverInvokeMethod() { $command = new class extends Command { public string $called; protected function execute(InputInterface $input, OutputInterface $output): int { $this->called = __FUNCTION__; return 0; } public function __invoke(): int { $this->called = __FUNCTION__; return 0; } }; $command->run(new ArrayInput([]), new NullOutput()); $this->assertSame('execute', $command->called); } public function testCallInvokeMethodWhenExtendingCommandClass() { $command = new class extends Command { public string $called; public function __invoke(): int { $this->called = __FUNCTION__; return 0; } }; $command->run(new ArrayInput([]), new NullOutput()); $this->assertSame('__invoke', $command->called); } public function testInvalidReturnType() { $command = new Command('foo'); $command->setCode(new class { public function __invoke() { } }); $this->expectException(\TypeError::class); $this->expectExceptionMessage('The command "foo" must return an integer value in the "__invoke" method, but "null" was returned.'); $command->run(new ArrayInput([]), new NullOutput()); } public function testGetCode() { $invokableTestCommand = new InvokableTestCommand(); $command = new Command(null, $invokableTestCommand); $this->assertSame($invokableTestCommand, $command->getCode()); } #[DataProvider('provideInputArguments')] public function testInputArguments(array $parameters, array $expected) { $command = new Command('foo'); $command->setCode(function ( #[Argument] string $a, #[Argument] ?string $b, #[Argument] string $c = '', #[Argument] array $d = [], ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); $this->assertSame($expected[3], $d); return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); } public static function provideInputArguments(): \Generator { yield 'required & defaults' => [['a' => 'x'], ['x', null, '', []]]; yield 'required & with-value' => [['a' => 'x', 'b' => 'y', 'c' => 'z', 'd' => ['d']], ['x', 'y', 'z', ['d']]]; yield 'required & without-value' => [['a' => 'x', 'b' => null, 'c' => null, 'd' => null], ['x', null, '', []]]; } #[DataProvider('provideBinaryInputOptions')] public function testBinaryInputOptions(array $parameters, array $expected) { $command = new Command('foo'); $command->setCode(function ( #[Option] bool $a = true, #[Option] bool $b = false, #[Option] ?bool $c = null, ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); } public static function provideBinaryInputOptions(): \Generator { yield 'defaults' => [[], [true, false, null]]; yield 'positive' => [['--a' => null, '--b' => null, '--c' => null], [true, true, true]]; yield 'negative' => [['--no-a' => null, '--no-c' => null], [false, false, false]]; } #[DataProvider('provideNonBinaryInputOptions')] public function testNonBinaryInputOptions(array $parameters, array $expected) { $command = new Command('foo'); $command->setCode(function ( #[Option] string $a = '', #[Option] array $b = [], #[Option] array $c = ['a', 'b'], #[Option] bool|string $d = false, #[Option] ?string $e = null, #[Option] ?array $f = null, #[Option] int $g = 0, #[Option] ?int $h = null, #[Option] float $i = 0.0, #[Option] ?float $j = null, #[Option] bool|int $k = false, #[Option] bool|float $l = false, ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); $this->assertSame($expected[3], $d); $this->assertSame($expected[4], $e); $this->assertSame($expected[5], $f); $this->assertSame($expected[6], $g); $this->assertSame($expected[7], $h); $this->assertSame($expected[8], $i); $this->assertSame($expected[9], $j); $this->assertSame($expected[10], $k); $this->assertSame($expected[11], $l); return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); } public static function provideNonBinaryInputOptions(): \Generator { yield 'defaults' => [ [], ['', [], ['a', 'b'], false, null, null, 0, null, 0.0, null, false, false], ]; yield 'with-value' => [ ['--a' => 'x', '--b' => ['z'], '--c' => ['c', 'd'], '--d' => 'v', '--e' => 'w', '--f' => ['q'], '--g' => 1, '--h' => 2, '--i' => 3.1, '--j' => 4.2, '--k' => 5, '--l' => 6.3], ['x', ['z'], ['c', 'd'], 'v', 'w', ['q'], 1, 2, 3.1, 4.2, 5, 6.3], ]; yield 'without-value' => [ ['--d' => null, '--k' => null, '--l' => null], ['', [], ['a', 'b'], true, null, null, 0, null, 0.0, null, true, true], ]; } #[DataProvider('provideInvalidOptionDefinitions')] public function testInvalidOptionDefinition(callable $code) { $command = new Command('foo'); $command->setCode($code); $this->expectException(LogicException::class); $command->getDefinition(); } public static function provideInvalidOptionDefinitions(): \Generator { yield 'no-default' => [ static function (#[Option] string $a) {}, ]; yield 'nullable-bool-default-true' => [ static function (#[Option] ?bool $a = true) {}, ]; yield 'nullable-bool-default-false' => [ static function (#[Option] ?bool $a = false) {}, ]; yield 'invalid-union-type' => [ static function (#[Option] array|bool $a = false) {}, ]; yield 'union-type-cannot-allow-null' => [ static function (#[Option] string|bool|null $a = null) {}, ]; yield 'union-type-default-true' => [ static function (#[Option] string|bool $a = true) {}, ]; yield 'union-type-default-string' => [ static function (#[Option] string|bool $a = 'foo') {}, ]; yield 'nullable-string-not-null-default' => [ static function (#[Option] ?string $a = 'foo') {}, ]; yield 'nullable-array-not-null-default' => [ static function (#[Option] ?array $a = []) {}, ]; } public function testInvalidRequiredValueOptionEvenWithDefault() { $command = new Command('foo'); $command->setCode(static function (#[Option] string $a = 'a') {}); $this->expectException(InvalidOptionException::class); $this->expectExceptionMessage('The "--a" option requires a value.'); $command->run(new ArrayInput(['--a' => null]), new NullOutput()); } public function testHelpersInjection() { $command = new Command('foo'); $command->setApplication(new Application()); $command->setCode(function ( InputInterface $input, OutputInterface $output, Cursor $cursor, SymfonyStyle $io, Application $application, Command $command, ): int { $this->addToAssertionCount(1); return 0; }); $command->run(new ArrayInput([]), new NullOutput()); } public function testDefaultArgumentResolversWithoutApplication() { $command = new Command('foo'); $command->setCode(static function ( \DateTime $date, #[Argument] string $name = 'default', ): int { Assert::assertInstanceOf(\DateTime::class, $date); Assert::assertSame('test', $name); return 0; }); $tester = new CommandTester($command); $tester->execute(['name' => 'test']); $tester->assertCommandIsSuccessful(); } public function testDefaultArgumentResolversWithMapDateTimeArgument() { $command = new Command('foo'); $command->setCode(static function ( #[Argument, MapDateTime] \DateTimeImmutable $date, ): int { Assert::assertInstanceOf(\DateTimeImmutable::class, $date); Assert::assertSame('2026-01-15', $date->format('Y-m-d')); return 0; }); $tester = new CommandTester($command); $tester->execute(['date' => '2026-01-15']); $tester->assertCommandIsSuccessful(); } public function testCustomArgumentResolverViaApplication() { $customArgumentResolver = new ArgumentResolver([ new CustomTypeValueResolver(), ...ArgumentResolver::getDefaultArgumentValueResolvers(), ]); $application = new Application(); $application->setArgumentResolver($customArgumentResolver); $command = new Command('foo'); $command->setCode(static function ( CustomType $custom, #[Argument] string $name = 'default', ): int { Assert::assertInstanceOf(CustomType::class, $custom); Assert::assertSame('resolved:from-app-test', $custom->value); Assert::assertSame('app-test', $name); return 0; }); $application->addCommand($command); $tester = new CommandTester($command); $tester->execute(['name' => 'app-test']); $tester->assertCommandIsSuccessful(); } public function testCommandInjection() { $application = new Application(); $command = new Command('test-cmd'); $command->setCode(static function ( Command $cmd, #[Argument] string $arg = 'default', ): int { Assert::assertInstanceOf(Command::class, $cmd); Assert::assertSame('test-cmd', $cmd->getName()); Assert::assertSame('value', $arg); return 0; }); $application->addCommand($command); $tester = new CommandTester($command); $tester->execute(['arg' => 'value']); $tester->assertCommandIsSuccessful(); } public function getSuggestedRoles(CompletionInput $input): array { return ['ROLE_ADMIN', 'ROLE_USER']; } public static function generateBioDescription(): string { return 'Generated bio description'; } public function testAskWithInputFileAndConstraints() { if (!\extension_loaded('fileinfo')) { $this->markTestSkipped('The "fileinfo" extension is required for this test.'); } $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); $tempFile = sys_get_temp_dir().'/test_image_'.uniqid().'.png'; file_put_contents($tempFile, $pngData); try { $tester = new CommandTester(new InvokableWithInputFileAndConstraintsTestCommand()); $tester->setInputs([$tempFile]); $tester->execute([], ['interactive' => true]); $tester->assertCommandIsSuccessful(); self::assertStringContainsString('Provide an image file:', $tester->getDisplay()); self::assertStringContainsString('Filename:', $tester->getDisplay()); self::assertStringContainsString('Valid: yes', $tester->getDisplay()); } finally { @unlink($tempFile); } } public function testAskWithCustomValidatorIsNotOverwritten() { $tester = new CommandTester(new InvokableWithCustomValidatorTestCommand()); $tester->setInputs(['invalid', 'valid']); $tester->execute([], ['interactive' => true]); $tester->assertCommandIsSuccessful(); self::assertStringContainsString('Enter a value:', $tester->getDisplay()); self::assertStringContainsString('Value must be "valid"', $tester->getDisplay()); self::assertStringContainsString('Value: valid', $tester->getDisplay()); } } enum StringEnum: string { case Image = 'image'; case Video = 'video'; } class CustomType { public function __construct(public string $value) { } } class CustomTypeValueResolver implements ValueResolverInterface { public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable { $type = $member->getType(); if (!$type instanceof \ReflectionNamedType || CustomType::class !== $type->getName()) { return []; } $name = $input->hasArgument('name') ? $input->getArgument('name') : 'default'; yield new CustomType('resolved:from-'.$name); } }