diff --git a/Application.php b/Application.php index 8c5d1b6a5..3c6314f82 100644 --- a/Application.php +++ b/Application.php @@ -775,6 +775,21 @@ public function find(string $name): Command })); } + // check whether all commands left are aliases to the same one + if (\count($commands) > 1) { + $uniqueCommands = array_unique(array_map(function ($nameOrAlias) use (&$commandList) { + if (!$commandList[$nameOrAlias] instanceof Command) { + $commandList[$nameOrAlias] = $this->commandLoader->get($nameOrAlias); + } + + return $commandList[$nameOrAlias]->getName(); + }, $commands)); + + if (1 === \count($uniqueCommands)) { + $commands = [reset($uniqueCommands)]; + } + } + if (\count($commands) > 1) { $usableWidth = $this->terminal->getWidth() - 10; $abbrevs = array_values($commands); diff --git a/Command/CompleteCommand.php b/Command/CompleteCommand.php index 15eeea16a..a6ce1d168 100644 --- a/Command/CompleteCommand.php +++ b/Command/CompleteCommand.php @@ -104,24 +104,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'Messages:', ]); - $command = $this->findCommand($completionInput); + if ($command = $this->findCommand($completionInput)) { + $command->mergeApplicationDefinition(); + $completionInput->bind($command->getDefinition()); + } if (null === $command) { $this->log(' No command found, completing using the Application class.'); $this->getApplication()->complete($completionInput, $suggestions); } elseif ( $completionInput->mustSuggestArgumentValuesFor('command') - && $command->getName() !== $completionInput->getCompletionValue() - && !\in_array($completionInput->getCompletionValue(), $command->getAliases(), true) ) { - $this->log(' No command found, completing using the Application class.'); + $this->log(' Command found, completing command name.'); // expand shortcut names ("cache:cl") into their full name ("cache:clear") - $suggestions->suggestValues(array_filter(array_merge([$command->getName()], $command->getAliases()))); + $commandNames = array_filter(array_merge([$command->getName()], $command->getAliases())); + foreach ($commandNames as $name) { + if (str_starts_with($name, $completionInput->getCompletionValue())) { + $commandNames = [$name]; + break; + } + } + $suggestions->suggestValues($commandNames); } else { - $command->mergeApplicationDefinition(); - $completionInput->bind($command->getDefinition()); - if (CompletionInput::TYPE_OPTION_NAME === $completionInput->getCompletionType()) { $this->log(' Completing option names for the '.($command instanceof LazyCommand ? $command->getCommand() : $command)::class.' command.'); diff --git a/Command/TraceableCommand.php b/Command/TraceableCommand.php index 96d2b33bf..8129ca803 100644 --- a/Command/TraceableCommand.php +++ b/Command/TraceableCommand.php @@ -178,9 +178,12 @@ public function setCode(callable $code): static 'file' => $r->getFileName(), 'line' => $r->getStartLine(), ]; - } - $this->command->setCode($code); + // Pass the original callable to avoid double-wrapping in Command::setCode() + $this->command->setCode($code->getCode()); + } else { + $this->command->setCode($code); + } return parent::setCode(function (InputInterface $input, OutputInterface $output) use ($code): int { $event = $this->stopwatch->start($this->getName().'.code'); @@ -287,8 +290,8 @@ public function run(InputInterface $input, OutputInterface $output): int { $this->input = $input; $this->output = $output; - $this->arguments = $input->getArguments(); - $this->options = $input->getOptions(); + $initialArguments = $input->getArguments(); + $initialOptions = $input->getOptions(); $event = $this->stopwatch->start($this->getName(), 'command'); try { @@ -303,9 +306,11 @@ public function run(InputInterface $input, OutputInterface $output): int $this->duration = $event->getDuration().' ms'; $this->maxMemoryUsage = ($event->getMemory() >> 20).' MiB'; - if ($this->isInteractive) { - $this->extractInteractiveInputs($input->getArguments(), $input->getOptions()); - } + $this->arguments = $input->getArguments(); + $this->options = $input->getOptions(); + + $this->extractInteractiveInputs($initialArguments, $initialOptions); + $this->isInteractive = $this->isInteractive || $this->interactiveInputs; } return $this->exitCode; @@ -344,22 +349,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $exitCode; } - private function extractInteractiveInputs(array $arguments, array $options): void + private function extractInteractiveInputs(array $initialArguments, array $initialOptions): void { - foreach ($arguments as $argName => $argValue) { - if (\array_key_exists($argName, $this->arguments) && $this->arguments[$argName] === $argValue) { + $nativeDefinition = $this->command->getNativeDefinition(); + + foreach ($nativeDefinition->getArguments() as $argName => $argument) { + if (\array_key_exists($argName, $initialArguments) && $initialArguments[$argName] === $this->arguments[$argName]) { continue; } - $this->interactiveInputs[$argName] = $argValue; + $this->interactiveInputs[$argName] = $this->arguments[$argName]; } - foreach ($options as $optName => $optValue) { - if (\array_key_exists($optName, $this->options) && $this->options[$optName] === $optValue) { + foreach ($nativeDefinition->getOptions() as $optName => $option) { + if (\array_key_exists($optName, $initialOptions) && $initialOptions[$optName] === $this->options[$optName]) { continue; } - $this->interactiveInputs['--'.$optName] = $optValue; + $this->interactiveInputs['--'.$optName] = $this->options[$optName]; } } } diff --git a/Exception/RunCommandFailedException.php b/Exception/RunCommandFailedException.php index 5d87ec949..e25ad2bdd 100644 --- a/Exception/RunCommandFailedException.php +++ b/Exception/RunCommandFailedException.php @@ -22,7 +22,7 @@ public function __construct(\Throwable|string $exception, public readonly RunCom { parent::__construct( $exception instanceof \Throwable ? $exception->getMessage() : $exception, - $exception instanceof \Throwable ? $exception->getCode() : 0, + $exception instanceof \Throwable && \is_int($exception->getCode()) ? $exception->getCode() : 0, $exception instanceof \Throwable ? $exception : null, ); } diff --git a/Helper/ProgressBar.php b/Helper/ProgressBar.php index dc3605ad2..3e0c74ab9 100644 --- a/Helper/ProgressBar.php +++ b/Helper/ProgressBar.php @@ -578,14 +578,14 @@ private static function initPlaceholderFormatters(): array }, 'elapsed' => fn (self $bar) => Helper::formatTime(time() - $bar->getStartTime(), 2), 'remaining' => function (self $bar) { - if (null === $bar->getMaxSteps()) { + if (null === $bar->max) { throw new LogicException('Unable to display the remaining time if the maximum number of steps is not set.'); } return Helper::formatTime($bar->getRemaining(), 2); }, 'estimated' => function (self $bar) { - if (null === $bar->getMaxSteps()) { + if (null === $bar->max) { throw new LogicException('Unable to display the estimated time if the maximum number of steps is not set.'); } diff --git a/Helper/ProgressIndicator.php b/Helper/ProgressIndicator.php index b6bbd0cfa..849c0589c 100644 --- a/Helper/ProgressIndicator.php +++ b/Helper/ProgressIndicator.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; /** @@ -149,7 +150,9 @@ public function finish(string $message/* , ?string $finishedIndicator = null */) $this->finished = true; $this->message = $message; $this->display(); - $this->output->writeln(''); + if (!$this->output instanceof ConsoleSectionOutput) { + $this->output->writeln(''); + } $this->started = false; } @@ -214,7 +217,9 @@ private function determineBestFormat(): string */ private function overwrite(string $message): void { - if ($this->output->isDecorated()) { + if ($this->output instanceof ConsoleSectionOutput) { + $this->output->overwrite($message); + } elseif ($this->output->isDecorated()) { $this->output->write("\x0D\x1B[2K"); $this->output->write($message); } else { diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 80e478f16..ee285ff57 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -465,6 +465,8 @@ private function validateAttempts(callable $interviewer, OutputInterface $output try { return $question->getValidator()($interviewer()); + } catch (MissingInputException $e) { + throw $error ?? $e; } catch (RuntimeException $e) { throw $e; } catch (\Exception $error) { diff --git a/Style/SymfonyStyle.php b/Style/SymfonyStyle.php index d0788e88d..519d71a5c 100644 --- a/Style/SymfonyStyle.php +++ b/Style/SymfonyStyle.php @@ -95,7 +95,7 @@ public function section(string $message): void public function listing(array $elements): void { $this->autoPrependText(); - $elements = array_map(fn ($element) => \sprintf(' * %s', $element), $elements); + $elements = array_map(static fn ($element) => \sprintf(' * %s', $element), $elements); $this->writeln($elements); $this->newLine(); @@ -437,12 +437,14 @@ private function createBlock(iterable $messages, ?string $type = null, ?string $ $message = OutputFormatter::escape($message); } + $message = str_replace("\r\n", "\n", $message); + $lines = array_merge( $lines, - explode(\PHP_EOL, $outputWrapper->wrap( + explode("\n", $outputWrapper->wrap( $message, $this->lineLength - $prefixLength - $indentLength, - \PHP_EOL + "\n" )) ); diff --git a/Terminal.php b/Terminal.php index 80f254434..61259f66d 100644 --- a/Terminal.php +++ b/Terminal.php @@ -128,7 +128,7 @@ public static function hasSttyAvailable(): bool return false; } - return self::$stty = (bool) shell_exec('stty 2> '.('\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null')); + return self::$stty = (bool) @shell_exec('stty 2> '.('\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null')); } private static function initDimensions(): void diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index e965aca90..420711735 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -60,6 +60,8 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\Process; class ApplicationTest extends TestCase @@ -114,6 +116,7 @@ public static function setUpBeforeClass(): void require_once self::$fixturesPath.'/TestAmbiguousCommandRegistering2.php'; require_once self::$fixturesPath.'/FooHiddenCommand.php'; require_once self::$fixturesPath.'/BarHiddenCommand.php'; + require_once self::$fixturesPath.'/ManyAliasesCommand.php'; } protected function normalizeLineBreaks($text) @@ -481,6 +484,18 @@ public function testFindCaseInsensitiveSuggestions() $application->find('FoO:BaR'); } + public function testFindSingleWithAmbiguousAliases() + { + $application = new Application(); + $application->addCommand(new \ManyAliasesCommand()); + $application->addCommand(new \AlternativeCommand()); + + $this->assertInstanceOf(\ManyAliasesCommand::class, $application->find('a'), '->find() will find the correct command using a short alias'); + $this->assertInstanceOf(\ManyAliasesCommand::class, $application->find('alias'), '->find() will find the correct command using a long alias'); + $this->assertInstanceOf(\ManyAliasesCommand::class, $application->find('aliased'), '->find() will find the correct command using the right name'); + $this->assertInstanceOf(\ManyAliasesCommand::class, $application->find('ali'), '->find() will find the correct command using an ambiguous shortened version'); + } + public function testFindWithCommandLoader() { $application = new Application(); @@ -2315,8 +2330,16 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool array_unshift($params, 'php'); $p = new Process($params); - $p->setTty(true); - $p->start(); + try { + $p->setTty(true); + $p->start(); + } catch (RuntimeException $e) { + if (str_contains($e->getMessage(), '/dev/tty')) { + $this->markTestSkipped('/dev/tty is not read/writable in this environment.'); + } + + throw $e; + } for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) { usleep(200000); @@ -2324,7 +2347,12 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $this->assertNotSame($previousSttyMode, shell_exec('stty -g')); $p->signal(\SIGINT); - $exitCode = $p->wait(); + try { + $exitCode = $p->wait(); + } catch (ProcessTimedOutException) { + $p->stop(0); + $this->markTestSkipped('TTY signal handling is not supported in this environment.'); + } $sttyMode = shell_exec('stty -g'); shell_exec('stty '.$previousSttyMode); diff --git a/Tests/Command/CompleteCommandTest.php b/Tests/Command/CompleteCommandTest.php index d837ac858..a1b94b773 100644 --- a/Tests/Command/CompleteCommandTest.php +++ b/Tests/Command/CompleteCommandTest.php @@ -102,10 +102,12 @@ public function testCompleteCommandName(array $input, array $suggestions) public static function provideCompleteCommandNameInputs() { - yield 'empty' => [['bin/console'], ['help', 'list', 'completion', 'hello', 'ahoy']]; - yield 'partial' => [['bin/console', 'he'], ['help', 'list', 'completion', 'hello', 'ahoy']]; - yield 'complete-shortcut-name' => [['bin/console', 'hell'], ['hello', 'ahoy']]; - yield 'complete-aliases' => [['bin/console', 'ah'], ['hello', 'ahoy']]; + yield 'empty' => [['bin/console'], ['help', 'list', 'completion', 'hello', 'ahoy', 'h', 'ahah']]; + yield 'partial' => [['bin/console', 'he'], ['help', 'list', 'completion', 'hello', 'ahoy', 'h', 'ahah']]; + yield 'complete-shortcut-name' => [['bin/console', 'hell'], ['hello']]; + yield 'complete-aliases' => [['bin/console', 'ah'], ['ahoy']]; + yield 'short-alias-completes-to-name' => [['bin/console', 'h'], ['hello']]; + yield 'ambiguous-of-same-command-completes-to-first-match' => [['bin/console', 'ah'], ['ahoy']]; } #[DataProvider('provideCompleteCommandInputDefinitionInputs')] @@ -121,6 +123,7 @@ public static function provideCompleteCommandInputDefinitionInputs() yield 'custom' => [['bin/console', 'hello'], ['Fabien', 'Robin', 'Wouter']]; yield 'definition-aliased' => [['bin/console', 'ahoy', '-'], ['--help', '--silent', '--quiet', '--verbose', '--version', '--ansi', '--no-ansi', '--no-interaction', '--global-option']]; yield 'custom-aliased' => [['bin/console', 'ahoy'], ['Fabien', 'Robin', 'Wouter']]; + yield 'custom-aliased-input' => [['bin/console', 'ahoy', 'Fa'], ['Fabien', 'Robin', 'Wouter']]; yield 'global-option-values' => [['bin/console', '--global-option'], ['foo', 'bar', 'baz']]; yield 'global-option-with-command-values' => [['bin/console', 'ahoy', '--global-option'], ['foo', 'bar', 'baz']]; } @@ -137,7 +140,7 @@ class CompleteCommandTest_HelloCommand extends Command public function configure(): void { $this->setName('hello') - ->setAliases(['ahoy']) + ->setAliases(['ahoy', 'h', 'ahah']) ->setDescription('Hello test command') ->addArgument('name', InputArgument::REQUIRED) ; diff --git a/Tests/Command/TraceableCommandTest.php b/Tests/Command/TraceableCommandTest.php index 4878b6ba7..f8f47cc05 100644 --- a/Tests/Command/TraceableCommandTest.php +++ b/Tests/Command/TraceableCommandTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Command\TraceableCommand; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; +use Symfony\Component\Console\Tests\Fixtures\InvokableWithAskCommand; use Symfony\Component\Console\Tests\Fixtures\LoopExampleCommand; use Symfony\Component\Stopwatch\Stopwatch; @@ -69,6 +70,37 @@ public function testRunOnInvokableCommand() $commandTester->assertCommandIsSuccessful(); } + public function testRunOnInvokableCommandWithAskAttribute() + { + $this->application->addCommand(new InvokableWithAskCommand()); + $command = $this->application->find('invokable:ask'); + $traceableCommand = new TraceableCommand($command, new Stopwatch()); + + $commandTester = new CommandTester($traceableCommand); + $commandTester->setInputs(['World']); + $commandTester->execute([], ['interactive' => true]); + $commandTester->assertCommandIsSuccessful(); + + self::assertStringContainsString('What is your name?', $commandTester->getDisplay()); + self::assertStringContainsString('Hello World', $commandTester->getDisplay()); + } + + public function testArgumentsCaptureValueSetDuringInteract() + { + $this->application->addCommand(new InvokableWithAskCommand()); + $command = $this->application->find('invokable:ask'); + $traceableCommand = new TraceableCommand($command, new Stopwatch()); + + $commandTester = new CommandTester($traceableCommand); + $commandTester->setInputs(['Robin']); + $commandTester->execute([], ['interactive' => true]); + $commandTester->assertCommandIsSuccessful(); + + self::assertSame('Robin', $traceableCommand->arguments['name']); + self::assertTrue($traceableCommand->isInteractive); + self::assertSame(['name' => 'Robin'], $traceableCommand->interactiveInputs); + } + public function assertLoopOutputCorrectness(string $output) { $completeChar = '\\' !== \DIRECTORY_SEPARATOR ? '▓' : '='; diff --git a/Tests/CursorTest.php b/Tests/CursorTest.php index d8ae705ea..3e5469691 100644 --- a/Tests/CursorTest.php +++ b/Tests/CursorTest.php @@ -184,7 +184,11 @@ public function testGetCurrentPosition() $this->assertEquals("\x1b[11;10H", $this->getOutputContent($output)); $isTtySupported = (bool) @proc_open('echo 1 >/dev/null', [['file', '/dev/tty', 'r'], ['file', '/dev/tty', 'w'], ['file', '/dev/tty', 'w']], $pipes); - $this->assertEquals($isTtySupported, '/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT)); + $isStreamTtySupported = '/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT); + + if ($isTtySupported !== $isStreamTtySupported) { + $this->markTestSkipped('TTY probing is inconsistent in this environment.'); + } if ($isTtySupported) { // When tty is supported, we can't validate the exact cursor position since it depends where the cursor is when the test runs. diff --git a/Tests/Exception/RunCommandFailedExceptionTest.php b/Tests/Exception/RunCommandFailedExceptionTest.php new file mode 100644 index 000000000..dcdfa2f48 --- /dev/null +++ b/Tests/Exception/RunCommandFailedExceptionTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Exception; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\RunCommandFailedException; +use Symfony\Component\Console\Messenger\RunCommandContext; +use Symfony\Component\Console\Messenger\RunCommandMessage; + +class RunCommandFailedExceptionTest extends TestCase +{ + public function testDefaultExceptionProvidesCode() + { + $exception = self::createException(new \Exception('Boom!', 42)); + + self::assertSame(42, $exception->getCode()); + } + + public function testNonIntegerCodeProvidesZero() + { + $exception = self::createException(new class extends \Exception { + protected $code = 'non-integer-code'; + }); + + self::assertSame(0, $exception->getCode()); + } + + private static function createException(\Throwable $inner): RunCommandFailedException + { + return new RunCommandFailedException( + $inner, + new RunCommandContext( + new RunCommandMessage('foo'), + exitCode: Command::FAILURE, + output: 'bar' + ) + ); + } +} diff --git a/Tests/Fixtures/InvokableWithAskCommand.php b/Tests/Fixtures/InvokableWithAskCommand.php new file mode 100644 index 000000000..d97f8179d --- /dev/null +++ b/Tests/Fixtures/InvokableWithAskCommand.php @@ -0,0 +1,34 @@ + + * + * 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\Attribute\Ask; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand('invokable:ask')] +class InvokableWithAskCommand +{ + public function __invoke( + SymfonyStyle $io, + + #[Argument] + #[Ask('What is your name?')] + string $name, + ): int { + $io->writeln('Hello '.$name); + + return Command::SUCCESS; + } +} diff --git a/Tests/Fixtures/ManyAliasesCommand.php b/Tests/Fixtures/ManyAliasesCommand.php new file mode 100644 index 000000000..216f8bc38 --- /dev/null +++ b/Tests/Fixtures/ManyAliasesCommand.php @@ -0,0 +1,22 @@ +setName('aliased') + ->setAliases(['a', 'alias', 'alias2', 'alias3']) + ->setDescription('Aliased command'); + } +} + +class AlternativeCommand extends Command +{ + protected function configure(): void + { + $this->setName('alternative') + ->setDescription('Aliased command 2'); + } +} diff --git a/Tests/Helper/HelperSetTest.php b/Tests/Helper/HelperSetTest.php index 014a745e8..42ea570d4 100644 --- a/Tests/Helper/HelperSetTest.php +++ b/Tests/Helper/HelperSetTest.php @@ -89,13 +89,14 @@ public function testIteration() private function getGenericMockHelper($name, ?HelperSet $helperset = null) { - $mock_helper = $this->createStub(HelperInterface::class); + $mock_helper = $helperset ? $this->createMock(HelperInterface::class) : $this->createStub(HelperInterface::class); $mock_helper ->method('getName') ->willReturn($name); if ($helperset) { $mock_helper + ->expects($this->once()) ->method('setHelperSet') ->with($this->equalTo($helperset)); } diff --git a/Tests/Helper/ProgressBarTest.php b/Tests/Helper/ProgressBarTest.php index 683bc7c87..55fc04b28 100644 --- a/Tests/Helper/ProgressBarTest.php +++ b/Tests/Helper/ProgressBarTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\Helper; use Symfony\Component\Console\Helper\ProgressBar; @@ -1371,4 +1372,24 @@ public function testGetNotSetMessage() $this->assertNull($progressBar->getMessage()); } + + public function testRemainingWithoutMaxThrowsLogicException() + { + $this->expectException(LogicException::class); + + $bar = new ProgressBar($this->getOutputStream()); + $bar->setFormat('%remaining%'); + $bar->start(); + $bar->advance(); + } + + public function testEstimatedWithoutMaxThrowsLogicException() + { + $this->expectException(LogicException::class); + + $bar = new ProgressBar($this->getOutputStream()); + $bar->setFormat('%estimated%'); + $bar->start(); + $bar->advance(); + } } diff --git a/Tests/Helper/ProgressIndicatorTest.php b/Tests/Helper/ProgressIndicatorTest.php index fb11d1432..1c975ab2e 100644 --- a/Tests/Helper/ProgressIndicatorTest.php +++ b/Tests/Helper/ProgressIndicatorTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\ProgressIndicator; +use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\StreamOutput; #[Group('time-sensitive')] @@ -201,6 +203,58 @@ public static function provideFormat(): array ]; } + public function testWithConsoleSectionOutput() + { + $sections = []; + $stream = fopen('php://memory', 'r+', false); + $output = new ConsoleSectionOutput($stream, $sections, StreamOutput::VERBOSITY_NORMAL, true, new OutputFormatter()); + + $bar = new ProgressIndicator($output, null, 100, ['-', '\\', '|', '/']); + $bar->start('Starting...'); + usleep(101000); + $bar->advance(); + $bar->finish('Done...'); + + rewind($stream); + $content = stream_get_contents($stream); + + // Must not use raw ANSI line-clear sequences — those corrupt ConsoleSectionOutput's internal line tracking + $this->assertStringNotContainsString("\x0D\x1B[2K", $content); + + // finish() must not add an extra trailing newline — ConsoleSectionOutput::overwrite() already ends with writeln() + $this->assertStringEndsWith(' ✔ Done...'.\PHP_EOL, $content); + } + + public function testMultipleSectionsWithProgressIndicators() + { + $sections = []; + $stream = fopen('php://memory', 'r+', false); + $formatter = new OutputFormatter(); + $section1 = new ConsoleSectionOutput($stream, $sections, StreamOutput::VERBOSITY_NORMAL, true, $formatter); + $section2 = new ConsoleSectionOutput($stream, $sections, StreamOutput::VERBOSITY_NORMAL, true, $formatter); + + $bar1 = new ProgressIndicator($section1, null, 100, ['-', '\\', '|', '/']); + $bar2 = new ProgressIndicator($section2, null, 100, ['-', '\\', '|', '/']); + + $bar1->start('Project 1...'); + $bar2->start('Project 2...'); + usleep(101000); + $bar1->advance(); + $bar2->advance(); + $bar1->finish('Project 1 Done.'); + $bar2->finish('Project 2 Done.'); + + rewind($stream); + $content = stream_get_contents($stream); + + // Must not use raw ANSI line-clear sequences + $this->assertStringNotContainsString("\x0D\x1B[2K", $content); + + // Both finished messages must appear in the output + $this->assertStringContainsString('Project 1 Done.', $content); + $this->assertStringContainsString('Project 2 Done.', $content); + } + protected function getOutputStream($decorated = true, $verbosity = StreamOutput::VERBOSITY_NORMAL) { return new StreamOutput(fopen('php://memory', 'r+', false), $verbosity, $decorated); diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 929dd60cf..b55f69a91 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -821,6 +821,26 @@ public function testAskThrowsExceptionOnMissingInputWithValidator() $dialog->ask($this->createStreamableInputInterfaceMock($this->getInputStream('')), $this->createOutputInterface(), $question); } + public function testValidatorExceptionPropagatesOnEmptyInput() + { + $dialog = new QuestionHelper(); + + $question = new Question('What\'s your name?'); + $question->setValidator(function ($value) { + if ('' === $value || null === $value) { + throw new \InvalidArgumentException('A value is required.'); + } + + return $value; + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('A value is required.'); + + // Simulate setInputs(['']), which writes "\n" to the stream then EOF + $dialog->ask($this->createStreamableInputInterfaceMock($this->getInputStream("\n")), $this->createOutputInterface(), $question); + } + public function testQuestionValidatorRepeatsThePrompt() { $tries = 0; diff --git a/Tests/Helper/TableTest.php b/Tests/Helper/TableTest.php index b6f57f560..85b55d7aa 100644 --- a/Tests/Helper/TableTest.php +++ b/Tests/Helper/TableTest.php @@ -2065,8 +2065,6 @@ public function testGithubIssue60038WidthOfCellWithEmoji() ->setHeaders(['Title', 'Author']) ->setRows([ ['🎭 💫 ☯ Divine Comedy', 'Dante Alighieri'], - // the snowflake (e2 9d 84 ef b8 8f) has a variant selector - ['👑 ❄️ 🗡 Game of Thrones', 'George R.R. Martin'], // the snowflake in text style (e2 9d 84 ef b8 8e) has a variant selector ['❄︎❄︎❄︎ snowflake in text style ❄︎❄︎❄︎', ''], ['And a very long line to show difference in previous lines', ''], @@ -2075,14 +2073,13 @@ public function testGithubIssue60038WidthOfCellWithEmoji() $table->render(); $this->assertSame(<<getOutputContent($output) diff --git a/Tests/Style/SymfonyStyleTest.php b/Tests/Style/SymfonyStyleTest.php index e7632b5b6..1212d5ade 100644 --- a/Tests/Style/SymfonyStyleTest.php +++ b/Tests/Style/SymfonyStyleTest.php @@ -95,6 +95,24 @@ public function testOutputProgressIterate() $this->assertStringEqualsFile($outputFilepath, $this->tester->getDisplay(true)); } + public function testBlockWithWindowsLineEndings() + { + $code = static function (InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + $io->block("First line.\r\nSecond line.", 'INFO', 'fg=white;bg=blue', ' ', true); + + return Command::SUCCESS; + }; + + $this->command->setCode($code); + $this->tester->execute([], ['interactive' => false, 'decorated' => false]); + + $display = $this->tester->getDisplay(true); + $this->assertStringNotContainsString("\r", $display); + $this->assertStringContainsString('First line.', $display); + $this->assertStringContainsString('Second line.', $display); + } + public function testGetErrorStyle() { $input = $this->createStub(InputInterface::class);