mirror of
https://github.com/symfony/console.git
synced 2026-03-24 01:12:13 +01:00
Add support for method-based commands with AsCommand attribute
This commit is contained in:
committed by
Yonel Ceruto
parent
4172fa755c
commit
0f8fadb397
@@ -14,7 +14,7 @@ namespace Symfony\Component\Console\Attribute;
|
||||
/**
|
||||
* Service tag to autoconfigure commands.
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
|
||||
final class AsCommand
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
8.1
|
||||
---
|
||||
|
||||
* Add support for method-based commands with `#[AsCommand]` attribute
|
||||
|
||||
8.0
|
||||
---
|
||||
|
||||
|
||||
@@ -61,21 +61,14 @@ class Command implements SignalableCommandInterface
|
||||
*/
|
||||
public function __construct(?string $name = null, ?callable $code = null)
|
||||
{
|
||||
if (null !== $code) {
|
||||
if (!\is_object($code) || $code instanceof \Closure) {
|
||||
throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s" or an invokable object.', self::class));
|
||||
}
|
||||
|
||||
/** @var AsCommand $attribute */
|
||||
$attribute = ((new \ReflectionObject($code))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance()
|
||||
?? throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class));
|
||||
$this->setCode($code);
|
||||
} else {
|
||||
$attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
}
|
||||
|
||||
$this->definition = new InputDefinition();
|
||||
|
||||
$attribute = $this->getCommandAttribute($code);
|
||||
|
||||
if ($code) {
|
||||
$this->setCode($code);
|
||||
}
|
||||
|
||||
if (null !== $name ??= $attribute?->name) {
|
||||
$aliases = explode('|', $name);
|
||||
|
||||
@@ -680,4 +673,34 @@ class Command implements SignalableCommandInterface
|
||||
throw new InvalidArgumentException(\sprintf('Command name "%s" is invalid.', $name));
|
||||
}
|
||||
}
|
||||
|
||||
private function getCommandAttribute(?callable $code): ?AsCommand
|
||||
{
|
||||
if (null === $code) {
|
||||
/** @var AsCommand|null $attribute */
|
||||
$attribute = (new \ReflectionClass(static::class)->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionFunction($code(...));
|
||||
|
||||
if ($reflection->isAnonymous() || !$class = $reflection->getClosureScopeClass()) {
|
||||
throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', self::class));
|
||||
}
|
||||
|
||||
/** @var AsCommand|null $attribute */
|
||||
$attribute = ($reflection->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
|
||||
if (!$attribute && $reflection->getName() === '__invoke') {
|
||||
/** @var AsCommand|null $attribute */
|
||||
$attribute = ($class->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
}
|
||||
|
||||
if (!$attribute) {
|
||||
throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class));
|
||||
}
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ class InvokableCommand implements SignalableCommandInterface
|
||||
OutputInterface::class => $output,
|
||||
Cursor::class => new Cursor($output),
|
||||
SymfonyStyle::class => new SymfonyStyle($input, $output),
|
||||
Command::class => $this->command,
|
||||
Application::class => $this->command->getApplication(),
|
||||
default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Definition;
|
||||
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
use Symfony\Component\DependencyInjection\TypedReference;
|
||||
@@ -32,12 +33,18 @@ class AddConsoleCommandPass implements CompilerPassInterface
|
||||
{
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
$commandServices = $container->findTaggedServiceIds('console.command', true);
|
||||
$commandServices = [];
|
||||
$lazyCommandMap = [];
|
||||
$lazyCommandRefs = [];
|
||||
$serviceIds = [];
|
||||
|
||||
foreach ($commandServices as $id => $tags) {
|
||||
foreach ($container->findTaggedServiceIds('console.command', true) as $id => $tags) {
|
||||
foreach ($tags as $tag) {
|
||||
$commandServices[$id][$tag['method'] ?? '__invoke'][] = $tag;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($commandServices as $id => $commands) {
|
||||
$definition = $container->getDefinition($id);
|
||||
$class = $container->getParameterBag()->resolveValue($definition->getClass());
|
||||
|
||||
@@ -45,94 +52,8 @@ class AddConsoleCommandPass implements CompilerPassInterface
|
||||
throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id));
|
||||
}
|
||||
|
||||
if (!$r->isSubclassOf(Command::class)) {
|
||||
if (!$r->hasMethod('__invoke')) {
|
||||
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "__invoke()" method.', $id, 'console.command', Command::class));
|
||||
}
|
||||
|
||||
$invokableRef = new Reference($id);
|
||||
$definition = $container->register($id .= '.command', $class = Command::class)
|
||||
->addMethodCall('setCode', [$invokableRef]);
|
||||
} else {
|
||||
$invokableRef = null;
|
||||
}
|
||||
|
||||
$definition->addTag('container.no_preload');
|
||||
|
||||
/** @var AsCommand|null $attribute */
|
||||
$attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
$defaultName = $attribute?->name;
|
||||
|
||||
$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? '');
|
||||
$aliases = explode('|', $aliases);
|
||||
$commandName = array_shift($aliases);
|
||||
|
||||
if ($isHidden = '' === $commandName) {
|
||||
$commandName = array_shift($aliases);
|
||||
}
|
||||
|
||||
if (null === $commandName) {
|
||||
if ($definition->isPrivate() || $definition->hasTag('container.private')) {
|
||||
$commandId = 'console.command.public_alias.'.$id;
|
||||
$container->setAlias($commandId, $id)->setPublic(true);
|
||||
$id = $commandId;
|
||||
}
|
||||
$serviceIds[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = $tags[0]['description'] ?? null;
|
||||
$help = $tags[0]['help'] ?? null;
|
||||
$usages = $tags[0]['usages'] ?? null;
|
||||
|
||||
unset($tags[0]);
|
||||
$lazyCommandMap[$commandName] = $id;
|
||||
$lazyCommandRefs[$id] = new TypedReference($id, $class);
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$lazyCommandMap[$alias] = $id;
|
||||
}
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (isset($tag['command'])) {
|
||||
$aliases[] = $tag['command'];
|
||||
$lazyCommandMap[$tag['command']] = $id;
|
||||
}
|
||||
|
||||
$description ??= $tag['description'] ?? null;
|
||||
$help ??= $tag['help'] ?? null;
|
||||
$usages ??= $tag['usages'] ?? null;
|
||||
}
|
||||
|
||||
$definition->addMethodCall('setName', [$commandName]);
|
||||
|
||||
if ($aliases) {
|
||||
$definition->addMethodCall('setAliases', [$aliases]);
|
||||
}
|
||||
|
||||
if ($isHidden) {
|
||||
$definition->addMethodCall('setHidden', [true]);
|
||||
}
|
||||
|
||||
if ($help && $invokableRef) {
|
||||
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
|
||||
}
|
||||
|
||||
if ($usages) {
|
||||
foreach ($usages as $usage) {
|
||||
$definition->addMethodCall('addUsage', [$usage]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($description ??= $attribute?->description) {
|
||||
$escapedDescription = str_replace('%', '%%', $description);
|
||||
$definition->addMethodCall('setDescription', [$escapedDescription]);
|
||||
|
||||
$container->register('.'.$id.'.lazy', LazyCommand::class)
|
||||
->setArguments([$commandName, $aliases, $escapedDescription, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
|
||||
|
||||
$lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy');
|
||||
foreach ($commands as $tags) {
|
||||
$this->registerCommand($container, $r, $id, $class, $tags, $definition, $serviceIds, $lazyCommandMap, $lazyCommandRefs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,4 +65,129 @@ class AddConsoleCommandPass implements CompilerPassInterface
|
||||
|
||||
$container->setParameter('console.command.ids', $serviceIds);
|
||||
}
|
||||
|
||||
private function registerCommand(ContainerBuilder $container, \ReflectionClass $reflection, string $id, string $class, array $tags, Definition $definition, array &$serviceIds, array &$lazyCommandMap, array &$lazyCommandRefs): void
|
||||
{
|
||||
if (!$reflection->isSubclassOf(Command::class)) {
|
||||
$method = $tags[0]['method'] ?? '__invoke';
|
||||
|
||||
if (!$reflection->hasMethod($method)) {
|
||||
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "%s()" method.', $id, 'console.command', Command::class, $method));
|
||||
}
|
||||
|
||||
$reflection = $reflection->getMethod($method);
|
||||
|
||||
if (!$reflection->isPublic() || $reflection->isStatic()) {
|
||||
throw new InvalidArgumentException(\sprintf('The method "%s::%s()" must be public and non-static to be used as a console command.', $class, $method));
|
||||
}
|
||||
|
||||
if ('__invoke' === $method) {
|
||||
$callableRef = new Reference($id);
|
||||
$id .= '.command';
|
||||
} else {
|
||||
$callableRef = [new Reference($id), $method];
|
||||
$id .= '.'.$method.'.command';
|
||||
}
|
||||
$class = Command::class;
|
||||
|
||||
$closureDefinition = new Definition(\Closure::class)
|
||||
->setFactory([\Closure::class, 'fromCallable'])
|
||||
->setArguments([$callableRef]);
|
||||
|
||||
$definition = $container->register($id, $class)
|
||||
->addMethodCall('setCode', [$closureDefinition]);
|
||||
} elseif (isset($tags[0]['method'])) {
|
||||
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "console.command" cannot define a method command when it is a subclass of "%s".', $id, Command::class));
|
||||
}
|
||||
|
||||
$definition->addTag('container.no_preload');
|
||||
|
||||
$attribute = $this->getCommandAttribute($reflection);
|
||||
$defaultName = $attribute?->name;
|
||||
$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? '');
|
||||
$aliases = explode('|', $aliases);
|
||||
$commandName = array_shift($aliases);
|
||||
|
||||
if ($isHidden = '' === $commandName) {
|
||||
$commandName = array_shift($aliases);
|
||||
}
|
||||
|
||||
if (null === $commandName) {
|
||||
if ($definition->isPrivate() || $definition->hasTag('container.private')) {
|
||||
$commandId = 'console.command.public_alias.'.$id;
|
||||
$container->setAlias($commandId, $id)->setPublic(true);
|
||||
$id = $commandId;
|
||||
}
|
||||
$serviceIds[] = $id;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$description = $tags[0]['description'] ?? null;
|
||||
$help = $tags[0]['help'] ?? null;
|
||||
$usages = $tags[0]['usages'] ?? null;
|
||||
|
||||
unset($tags[0]);
|
||||
$lazyCommandMap[$commandName] = $id;
|
||||
$lazyCommandRefs[$id] = new TypedReference($id, $class);
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$lazyCommandMap[$alias] = $id;
|
||||
}
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (isset($tag['command'])) {
|
||||
$aliases[] = $tag['command'];
|
||||
$lazyCommandMap[$tag['command']] = $id;
|
||||
}
|
||||
|
||||
$description ??= $tag['description'] ?? null;
|
||||
$help ??= $tag['help'] ?? null;
|
||||
$usages ??= $tag['usages'] ?? null;
|
||||
}
|
||||
|
||||
$definition->addMethodCall('setName', [$commandName]);
|
||||
|
||||
if ($aliases) {
|
||||
$definition->addMethodCall('setAliases', [$aliases]);
|
||||
}
|
||||
|
||||
if ($isHidden) {
|
||||
$definition->addMethodCall('setHidden', [true]);
|
||||
}
|
||||
|
||||
if ($help ??= $attribute?->help) {
|
||||
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
|
||||
}
|
||||
|
||||
if ($usages ??= $attribute?->usages) {
|
||||
foreach ($usages as $usage) {
|
||||
$definition->addMethodCall('addUsage', [$usage]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($description ??= $attribute?->description) {
|
||||
$escapedDescription = str_replace('%', '%%', $description);
|
||||
$definition->addMethodCall('setDescription', [$escapedDescription]);
|
||||
|
||||
$container->register('.'.$id.'.lazy', LazyCommand::class)
|
||||
->setArguments([$commandName, $aliases, $escapedDescription, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
|
||||
|
||||
$lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy');
|
||||
}
|
||||
}
|
||||
|
||||
private function getCommandAttribute(\ReflectionClass|\ReflectionMethod $reflection): ?AsCommand
|
||||
{
|
||||
/** @var AsCommand|null $attribute */
|
||||
if ($attribute = ($reflection->getAttributes(AsCommand::class)[0] ?? null)?->newInstance()) {
|
||||
return $attribute;
|
||||
}
|
||||
|
||||
if ($reflection instanceof \ReflectionMethod && '__invoke' === $reflection->getName()) {
|
||||
return ($reflection->getDeclaringClass()->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +290,9 @@ class ApplicationTest extends TestCase
|
||||
|
||||
public static function provideInvalidInvokableCommands(): iterable
|
||||
{
|
||||
yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)];
|
||||
yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', Command::class)];
|
||||
yield 'a closure' => [static function () {
|
||||
}, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)];
|
||||
}, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', Command::class)];
|
||||
yield 'without the #[AsCommand] attribute' => [new class {
|
||||
public function __invoke()
|
||||
{
|
||||
|
||||
@@ -475,6 +475,7 @@ class InvokableCommandTest extends TestCase
|
||||
Cursor $cursor,
|
||||
SymfonyStyle $io,
|
||||
Application $application,
|
||||
Command $command,
|
||||
): int {
|
||||
$this->addToAssertionCount(1);
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ use Symfony\Component\Console\Command\LazyCommand;
|
||||
use Symfony\Component\Console\Command\SignalableCommandInterface;
|
||||
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
|
||||
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Console\Tests\Fixtures\MethodBasedTestCommand;
|
||||
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
|
||||
use Symfony\Component\DependencyInjection\ChildDefinition;
|
||||
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
|
||||
@@ -233,6 +235,38 @@ class AddConsoleCommandPassTest extends TestCase
|
||||
$this->assertTrue($container->hasAlias($aliasPrefix.'my-command2'));
|
||||
}
|
||||
|
||||
public function testProcessMultiCommandSameClass()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
|
||||
$definition = new Definition(MethodBasedTestCommand::class);
|
||||
$definition->addTag('console.command');
|
||||
$definition->addTag('console.command', ['method' => 'cmd1']);
|
||||
$definition->addTag('console.command', ['method' => 'cmd2']);
|
||||
|
||||
$container->setDefinition(MethodBasedTestCommand::class, $definition);
|
||||
|
||||
new AddConsoleCommandPass()->process($container);
|
||||
$container->compile();
|
||||
|
||||
/** @var ContainerCommandLoader $loader */
|
||||
$loader = $container->get('console.command_loader');
|
||||
|
||||
$this->assertSame(['app:cmd0', 'app:cmd1', 'app:cmd2'], $loader->getNames());
|
||||
|
||||
$commandTester = new CommandTester($loader->get('app:cmd0'));
|
||||
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
|
||||
$this->assertSame('cmd0', $commandTester->getDisplay());
|
||||
|
||||
$commandTester = new CommandTester($loader->get('app:cmd1'));
|
||||
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
|
||||
$this->assertSame('cmd1', $commandTester->getDisplay());
|
||||
|
||||
$commandTester = new CommandTester($loader->get('app:cmd2'));
|
||||
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
|
||||
$this->assertSame('cmd2', $commandTester->getDisplay());
|
||||
}
|
||||
|
||||
public function testProcessOnChildDefinitionWithClass()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
|
||||
44
Tests/Fixtures/MethodBasedTestCommand.php
Normal file
44
Tests/Fixtures/MethodBasedTestCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?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\Fixtures;
|
||||
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('app:cmd0')]
|
||||
class MethodBasedTestCommand
|
||||
{
|
||||
public function __invoke(OutputInterface $o): int
|
||||
{
|
||||
$o->write('cmd0');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
#[AsCommand('app:cmd1')]
|
||||
public function cmd1(OutputInterface $o, #[Argument] ?string $name = null): int
|
||||
{
|
||||
$o->write('cmd1');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
#[AsCommand('app:cmd2')]
|
||||
public function cmd2(OutputInterface $o): int
|
||||
{
|
||||
$o->write('cmd2');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand;
|
||||
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputTestCommand;
|
||||
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInteractiveAttributesTestCommand;
|
||||
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInteractiveHiddenQuestionAttributeTestCommand;
|
||||
use Symfony\Component\Console\Tests\Fixtures\MethodBasedTestCommand;
|
||||
|
||||
class CommandTesterTest extends TestCase
|
||||
{
|
||||
@@ -322,6 +323,26 @@ class CommandTesterTest extends TestCase
|
||||
$tester->assertCommandIsSuccessful();
|
||||
}
|
||||
|
||||
public function testCallableMethodCommands()
|
||||
{
|
||||
$command = new MethodBasedTestCommand();
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
$tester->assertCommandIsSuccessful();
|
||||
$this->assertSame('cmd0', $tester->getDisplay());
|
||||
|
||||
$tester = new CommandTester($command->cmd1(...));
|
||||
$tester->execute([]);
|
||||
$tester->assertCommandIsSuccessful();
|
||||
$this->assertSame('cmd1', $tester->getDisplay());
|
||||
|
||||
$tester = new CommandTester($command->cmd2(...));
|
||||
$tester->execute([]);
|
||||
$tester->assertCommandIsSuccessful();
|
||||
$this->assertSame('cmd2', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testInvokableDefinitionWithInputAttribute()
|
||||
{
|
||||
$application = new Application();
|
||||
@@ -353,6 +374,30 @@ class CommandTesterTest extends TestCase
|
||||
self::assertStringMatchesFormat($expectedOutput, $bufferedOutput->fetch());
|
||||
}
|
||||
|
||||
public function testMethodBasedCommandWithApplication()
|
||||
{
|
||||
$command = new MethodBasedTestCommand();
|
||||
|
||||
$application = new Application();
|
||||
$application->addCommand($command->cmd1(...));
|
||||
$application->setAutoExit(false);
|
||||
|
||||
$bufferedOutput = new BufferedOutput();
|
||||
$statusCode = $application->run(new ArrayInput(['command' => 'help', 'command_name' => 'app:cmd1']), $bufferedOutput);
|
||||
|
||||
$expectedOutput = <<<TXT
|
||||
Usage:
|
||||
app:cmd1 [<name>]
|
||||
|
||||
Arguments:
|
||||
name %S
|
||||
%A
|
||||
TXT;
|
||||
|
||||
self::assertSame(0, $statusCode);
|
||||
self::assertStringMatchesFormat($expectedOutput, $bufferedOutput->fetch());
|
||||
}
|
||||
|
||||
#[DataProvider('getInvokableWithInputData')]
|
||||
public function testInvokableWithInputAttribute(array $input, string $output)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user