diff --git a/src/Symfony/Component/Console/Output/CombinedOutput.php b/src/Symfony/Component/Console/Output/CombinedOutput.php new file mode 100644 index 0000000000000..f1fe20d8323ed --- /dev/null +++ b/src/Symfony/Component/Console/Output/CombinedOutput.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Console\Output; + +use DomainException; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use function count; +use function func_get_args; + +/** + * @internal + */ +final class CombinedOutput implements OutputInterface +{ + /** + * @var OutputInterface[] + */ + private array $outputs; + + public function __construct( + OutputInterface ...$outputs, + ) { + self::assertHasAtLeastOneOutput($outputs); + + $this->outputs = $outputs; + } + + public function write(iterable|string $messages, bool $newline = false, int $options = 0): void + { + foreach ($this->outputs as $output) { + $output->write(...func_get_args()); + } + } + + public function writeln(iterable|string $messages, int $options = 0): void + { + foreach ($this->outputs as $output) { + $output->writeln(...func_get_args()); + } + } + + public function setVerbosity(int $level): void + { + throw new DomainException('Should not called.'); + } + + public function getVerbosity(): int + { + throw new DomainException('Should not called.'); + } + + public function isSilent(): bool + { + throw new DomainException('Should not called.'); + } + + public function isQuiet(): bool + { + throw new DomainException('Should not called.'); + } + + public function isVerbose(): bool + { + throw new DomainException('Should not called.'); + } + + public function isVeryVerbose(): bool + { + throw new DomainException('Should not called.'); + } + + public function isDebug(): bool + { + throw new DomainException('Should not called.'); + } + + public function setDecorated(bool $decorated): void + { + throw new DomainException('Should not called.'); + } + + public function isDecorated(): bool + { + throw new DomainException('Should not called.'); + } + + public function setFormatter(OutputFormatterInterface $formatter): void + { + throw new DomainException('Should not called.'); + } + + public function getFormatter(): OutputFormatterInterface + { + throw new DomainException('Should not called.'); + } + + /** + * @param OutputInterface[] $outputs + */ + private static function assertHasAtLeastOneOutput(array $outputs): void + { + if (count($outputs) < 1) { + throw new DomainException('Expected at least one output.'); + } + } +} diff --git a/src/Symfony/Component/Console/Output/StreamOutput.php b/src/Symfony/Component/Console/Output/StreamOutput.php index ce5a825e87808..61a89825a3f98 100644 --- a/src/Symfony/Component/Console/Output/StreamOutput.php +++ b/src/Symfony/Component/Console/Output/StreamOutput.php @@ -13,6 +13,8 @@ use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use function rewind; +use function stream_get_contents; /** * StreamOutput writes the output to a given stream. @@ -74,6 +76,7 @@ protected function doWrite(string $message, bool $newline): void fflush($this->stream); } + // TODO: should be public & static? /** * Returns true if the stream supports colorization. * diff --git a/src/Symfony/Component/Console/Output/TestOutput.php b/src/Symfony/Component/Console/Output/TestOutput.php new file mode 100644 index 0000000000000..1ace8ecd17490 --- /dev/null +++ b/src/Symfony/Component/Console/Output/TestOutput.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Console\Output; + +use DomainException; +use RuntimeException; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use function fopen; +use function func_get_args; +use function rewind; +use function stream_get_contents; + +/** + * @internal + * + * @author Théo FIDRY + */ +final class TestOutput implements ConsoleOutputInterface +{ + private OutputInterface $innerOutput; + private OutputInterface $innerErrorOutput; + private OutputInterface $displayOutput; + private CombinedOutput $output; + private CombinedOutput $errorOutput; + + /** + * @param OutputInterface::VERBOSITY_* $verbosity + */ + public function __construct( + private int $verbosity, + private OutputFormatterInterface $formatter, + ) { + $this->innerOutput = self::createOutput($this); + $this->innerErrorOutput = self::createOutput($this); + $this->displayOutput = self::createOutput($this); + + $this->output = new CombinedOutput( + $this->innerOutput, + $this->displayOutput, + ); + $this->errorOutput = new CombinedOutput( + $this->innerErrorOutput, + $this->displayOutput, + ); + } + + public function getOutputContents(): string + { + return $this->getStreamContents($this->innerOutput); + } + + public function getErrorOutputContents(): string + { + return $this->getStreamContents($this->innerErrorOutput); + } + + public function getDisplayContents(): string + { + return $this->getStreamContents($this->displayOutput); + } + + public function getErrorOutput(): OutputInterface + { + return $this->errorOutput; + } + + public function setErrorOutput(OutputInterface $error): void + { + throw new DomainException('Should not be modified.'); + } + + public function section(): ConsoleSectionOutput + { + throw new DomainException('Not supported (yet).'); + } + + public function write(iterable|string $messages, bool $newline = false, int $options = 0): void + { + $this->output->write(...func_get_args()); + } + + public function writeln(iterable|string $messages, int $options = 0): void + { + $this->output->writeln(...func_get_args()); + } + + public function setVerbosity(int $level): void + { + throw new DomainException('Should not be modified.'); + } + + public function getVerbosity(): int + { + return $this->verbosity; + } + + public function isSilent(): bool + { + return self::VERBOSITY_SILENT <= $this->verbosity; + } + + public function isQuiet(): bool + { + return self::VERBOSITY_QUIET === $this->verbosity; + } + + public function isVerbose(): bool + { + return self::VERBOSITY_VERBOSE <= $this->verbosity; + } + + public function isVeryVerbose(): bool + { + return self::VERBOSITY_VERY_VERBOSE <= $this->verbosity; + } + + public function isDebug(): bool + { + return self::VERBOSITY_DEBUG <= $this->verbosity; + } + + public function setDecorated(bool $decorated): void + { + throw new DomainException('Should not be modified.'); + } + + public function isDecorated(): bool + { + return $this->formatter->isDecorated(); + } + + public function setFormatter(OutputFormatterInterface $formatter): void + { + throw new DomainException('Should not be modified.'); + } + + public function getFormatter(): OutputFormatterInterface + { + return $this->formatter; + } + + private static function createOutput(OutputInterface $config): StreamOutput + { + $stream = fopen('php://memory', 'wb'); + + if (false === $stream) { + throw new RuntimeException('Failed to open stream.'); + } + + return new StreamOutput( + $stream, + $config->getVerbosity(), + $config->isDecorated(), + $config->getFormatter(), + ); + } + + private function getStreamContents(StreamOutput $output): string + { + $stream = $output->getStream(); + + rewind($stream); + + return stream_get_contents($stream); + } +} diff --git a/src/Symfony/Component/Console/Tester/CommandTester.php b/src/Symfony/Component/Console/Tester/CommandTester.php index 714d88ad51dea..77083fd5dd2f5 100644 --- a/src/Symfony/Component/Console/Tester/CommandTester.php +++ b/src/Symfony/Component/Console/Tester/CommandTester.php @@ -12,24 +12,70 @@ namespace Symfony\Component\Console\Tester; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\TestOutput; /** * Eases the testing of console commands. * * @author Fabien Potencier * @author Robin Chalas + * @author Théo FIDRY */ class CommandTester { use TesterTrait; - private Command $command; + private OutputFormatterInterface $outputFormatter; + /** + * @param OutputInterface::VERBOSITY_* $verbosity + */ public function __construct( - callable|Command $command, + private Command $command, + // TODO: to discuss if we want it in the constructor with the setters or only in the ::run() method + private ?bool $interactive = null, + private bool $decorated = false, + private int $verbosity = ConsoleOutput::VERBOSITY_NORMAL, + OutputFormatterInterface $outputFormatter = null, ) { - $this->command = $command instanceof Command ? $command : new Command(null, $command); + $this->outputFormatter = $outputFormatter ?? new OutputFormatter(); + } + + // TODO: to discuss if we want fluent setters + /** + * Sets the input interactivity. + */ + public function setInteractive(bool $interactive): void + { + $this->interactive = $interactive; + } + + /** + * Sets the decorated flag. + */ + public function setDecorated(bool $decorated): void + { + $this->decorated = $decorated; + } + + /** + * Sets the verbosity of the output. + * + * @param self::VERBOSITY_* $level + */ + public function setVerbosity(int $level): void + { + $this->verbosity = $level; + } + + public function setOutputFormatter(OutputFormatterInterface $outputFormatter): void + { + $this->outputFormatter = $outputFormatter; } /** @@ -49,29 +95,85 @@ public function __construct( */ public function execute(array $input, array $options = []): int { - // set the command name automatically if the application requires - // this argument and no command name was passed - if (!isset($input['command']) - && (null !== $application = $this->command->getApplication()) - && $application->getDefinition()->hasArgument('command') - ) { - $input = array_merge(['command' => $this->command->getName()], $input); - } - - $this->input = new ArrayInput($input); - // Use an in-memory input stream even if no inputs are set so that QuestionHelper::ask() does not rely on the blocking STDIN. - $this->input->setStream(self::createStream($this->inputs)); - - if (isset($options['interactive'])) { - $this->input->setInteractive($options['interactive']); - } + $this->input = $this->createInput( + self::addCommandArgumentIfNecessary($input), + $this->inputs, + $options['interactive'] ?? $this->interactive, + ); if (!isset($options['decorated'])) { - $options['decorated'] = false; + $options['decorated'] = $this->decorated; } $this->initOutput($options); return $this->statusCode = $this->command->run($this->input, $this->output); } + + /** + * Runs the command. + * + * @param array $input An array of command arguments and options + * @param string[] $interactiveInputs An array of strings representing each input + * passed to the command input stream + * @param OutputInterface::VERBOSITY_* $verbosity + */ + public function run( + array $input = [], + array $interactiveInputs = [], + ?bool $interactive = null, + ?bool $decorated = null, + ?int $verbosity = null, + ): ExecutionResult + { + $input = $this->createInput( + self::addCommandArgumentIfNecessary($input), + $interactiveInputs, + $interactive, + ); + + $testOutput = new TestOutput( + $decorated ?? $this->decorated, + $verbosity ?? $this->verbosity, + $this->outputFormatter, + ); + + $statusCode = $this->command->run($input, $testOutput); + + return ExecutionResult::fromExecution($input, $statusCode, $testOutput); + } + + private function addCommandArgumentIfNecessary(array $input): array + { + if (isset($input['command'])) { + return $input; + } + + $application = $this->command->getApplication(); + + return null !== $application && $application->getDefinition()->hasArgument('command') + ? array_merge( + ['command' => $this->command->getName()], + $input, + ) + : $input; + } + + private function createInput( + array $rawInput, + array $interactiveInputs = [], + ?bool $interactive = null, + ): InputInterface + { + $input = new ArrayInput($rawInput); + // Use an in-memory input stream even if no inputs are set so that QuestionHelper::ask() does not rely on the blocking STDIN. + $input->setStream(self::createStream($interactiveInputs)); + + $interactive ??= $this->interactive; + if (null !== $interactive) { + $input->setInteractive($interactive); + } + + return $input; + } } diff --git a/src/Symfony/Component/Console/Tester/ConsoleAssertionsTrait.php b/src/Symfony/Component/Console/Tester/ConsoleAssertionsTrait.php new file mode 100644 index 0000000000000..b562e2afd8116 --- /dev/null +++ b/src/Symfony/Component/Console/Tester/ConsoleAssertionsTrait.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tester; + +use PHPUnit\Framework\Assert; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful; +use function rewind; +use function str_replace; +use function stream_get_contents; + +/** + * @psalm-require-extends \PHPUnit\Framework\TestCase + * + * @author Théo FIDRY + */ +trait ConsoleAssertionsTrait +{ + public function assertIsSuccessful(ExecutionResult $result, string $message = ''): void + { + $this->assertThat($result->statusCode, new CommandIsSuccessful(), $message); + } + + public function assertStatusCodeEquals(int $expected, ExecutionResult $result, string $message = ''): void + { + $this->assertSame($expected, $result->statusCode, $message); + } + + // TODO: here we need to pass the result first if we want to keep some arguments optional. + // Not wanting this is an argument for having the assertions in the ExecutionResult instead. + public function assertResultEquals( + ExecutionResult $result, + ?int $expectedStatusCode = null, + ?string $expectedOutput = null, + ?string $expectedErrorOutput = null, + ?string $expectedDisplay = null, + string $message = '', + ): void + { + $expected = [ + 'statusCode' => $expectedStatusCode, + 'output' => $expectedOutput, + 'errorOutput' => $expectedErrorOutput, + 'display' => $expectedDisplay, + ]; + + $actual = [ + 'statusCode' => $result->statusCode, + 'output' => $result->getOutput(), + 'errorOutput' => $result->getErrorOutput(), + 'display' => $result->getDisplay(), + ]; + + $this->assertEquals($expected, $actual, $message); + } +} diff --git a/src/Symfony/Component/Console/Tester/ExecutionResult.php b/src/Symfony/Component/Console/Tester/ExecutionResult.php new file mode 100644 index 0000000000000..7fafffec300a3 --- /dev/null +++ b/src/Symfony/Component/Console/Tester/ExecutionResult.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tester; + +use Closure; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Console\Output\TestOutput; +use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful; +use function array_filter; +use function call_user_func; +use function function_exists; +use function implode; +use function rewind; +use function str_replace; +use function stream_get_contents; +use const PHP_EOL; + +/** + * @author Théo FIDRY + */ +final class ExecutionResult +{ + // This is purely for memoizing purposes + private array $results = []; + + /** + * @param array $normalizers + */ + public static function fromExecution( + InputInterface $input, + int $statusCode, + TestOutput $output, + array $normalizers, + ): self + { + return new self( + $input->__toString(), + $statusCode, + $output, + $normalizers, + ); + } + + /** + * @param array $normalizers + */ + public function __construct( + public readonly string $input, + public readonly int $statusCode, + private readonly TestOutput $output, + private readonly array $normalizers, + ) { + } + + /** + * Gets the display returned by the execution of the command or application. The display combines what was + * written on both the output and error output. + */ + public function getDisplay(bool $normalize = true): string + { + if (!isset($this->results['display'][$normalize])) { + $this->results['display'][$normalize] = $this->normalize( + $this->output->getDisplayContents($normalize), + $normalize, + ); + } + + return $this->results['display'][$normalize]; + } + + /** + * Gets the output written to the output by the command or application. + */ + public function getOutput(bool $normalize = false): string + { + if (!isset($this->results['output'][$normalize])) { + $this->results['output'][$normalize] = $this->normalize( + $this->output->getOutputContents(), + $normalize, + ); + } + + return $this->results['output'][$normalize]; + } + + /** + * Gets the output written to the error output by the command or application. + */ + public function getErrorOutput(bool $normalize = false): string + { + if (!isset($this->results['errorOutput'][$normalize])) { + $this->results['errorOutput'][$normalize] = $this->normalize( + $this->output->getErrorOutputContents(), + $normalize, + ); + } + + return $this->results['errorOutput'][$normalize]; + } + + public function dump(): self + { + $summary = "CLI: {$this->input}, Status: {$this->statusCode()}"; + $output = [ + $summary, + $this->output(), + $this->errorOutput(), + $summary, + ]; + + call_user_func( + function_exists('dump') ? 'dump' : 'var_dump', + implode("\n\n", array_filter($output)), + ); + + return $this; + } + + public function dd(): void + { + $this->dump(); + exit(1); + } + + private function normalize(string $value, bool $normalize): string + { + if (!$normalize) { + return $value; + } + + $normalizedValue = $value; + + foreach ($this->normalizers as $normalizer) { + $normalizedValue = $normalizer($normalizedValue); + } + + return $normalizedValue; + } +} diff --git a/src/Symfony/Component/Console/Tester/OutputNormalizers.php b/src/Symfony/Component/Console/Tester/OutputNormalizers.php new file mode 100644 index 0000000000000..228de32212511 --- /dev/null +++ b/src/Symfony/Component/Console/Tester/OutputNormalizers.php @@ -0,0 +1,38 @@ + + */ +final class OutputNormalizers +{ + public static function normalizeEndOfLines(string $value): string + { + return str_replace(PHP_EOL, "\n", $value); + } + + public static function removeTrailingSpaces(string $value): string + { + $lines = explode("\n", $value); + + $trimmedLines = array_map(rtrim(...), $lines); + + return implode("\n", $trimmedLines); + } + + private function __construct() + { + } +} diff --git a/src/Symfony/Component/Console/Tester/TesterTrait.php b/src/Symfony/Component/Console/Tester/TesterTrait.php index 1ab7a70aa22d9..63ade1915bff0 100644 --- a/src/Symfony/Component/Console/Tester/TesterTrait.php +++ b/src/Symfony/Component/Console/Tester/TesterTrait.php @@ -19,6 +19,8 @@ use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful; /** + * @deprecated + * * @author Amrouche Hamza */ trait TesterTrait diff --git a/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php b/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php index b7490ad3f6965..4e287538e056b 100644 --- a/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php +++ b/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php @@ -25,9 +25,13 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tests\Fixtures\InvokableExtendingCommandTestCommand; use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; +use Symfony\Component\Console\Tester\ConsoleAssertionsTrait; +use function implode; class CommandTesterTest extends TestCase { + use ConsoleAssertionsTrait; + protected Command $command; protected CommandTester $tester; @@ -289,4 +293,104 @@ public function testAInvokableExtendedCommand() $tester->assertCommandIsSuccessful(); } + + public function testItExecutesTheTestedCommandWithTheSameConfigAsThePreviousApiByDefault() + { + $oldConfig = []; + $newConfig = []; + + $oldTesterCommand = self::createDisplayConfigurationCommand($oldConfig); + $newTesterCommand = self::createDisplayConfigurationCommand($newConfig); + + $oldTester = new CommandTester($oldTesterCommand); + $oldTester->execute([]); + + $newTester = new CommandTester($newTesterCommand); + $newTester->run(); + + // Sanity check + $this->assertNotEquals([], $oldConfig); + $this->assertEquals($oldConfig, $newConfig); + } + + public function testItCanConfigureTheExecutedCommand() + { + $config = []; + + $test = new CommandTester( + self::createDisplayConfigurationCommand($config), + ); + $test->run( + interactive: true, + decorated: false, + verbosity: OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + $expectedConfig = [ + 'interactive' => true, + 'decorated' => false, + 'verbosity' => OutputInterface::VERBOSITY_VERY_VERBOSE, + ]; + + $this->assertEquals($expectedConfig, $config); + } + + public function testItCanTestTheExecutionResult() + { + $command = new Command('foo'); + $command->setCode(function (InputInterface $input, OutputInterface $output) { + $output->writeln('bar'); + + return 0; + }); + + $result = (new CommandTester($command))->run(); + + $this->assertIsSuccessful($result); + $this->assertStatusCodeEquals(0, $result); + $this->assertSame("bar\n", $result->getDisplay()); + } + + public function testItProvidesUserInputs() + { + $questions = [ + 'What\'s your name?', + 'How are you?', + 'Where do you come from?', + ]; + + $command = new Command('foo'); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $command->setCode(function ($input, $output) use ($questions, $command) { + $helper = $command->getHelper('question'); + $helper->ask($input, $output, new Question($questions[0])); + $helper->ask($input, $output, new Question($questions[1])); + $helper->ask($input, $output, new Question($questions[2])); + }); + + $tester = new CommandTester($command); + $result = $tester->run(interactiveInputs: ['Bobby', 'Fine', 'France']); + + $this->assertResultEquals( + $result, + 0, + '', + implode('', $questions), + implode('', $questions), + ); + } + + private static function createDisplayConfigurationCommand(array &$config): Command + { + $command = new Command('foo'); + $command->setCode(function (InputInterface $input, OutputInterface $output) use (&$config) { + $config['interactive'] = $input->isInteractive(); + $config['verbosity'] = $output->getVerbosity(); + $config['decorated'] = $output->isDecorated(); + + return 0; + }); + + return $command; + } }