diff --git a/setup/src/Magento/Setup/Console/InputValidationException.php b/setup/src/Magento/Setup/Console/InputValidationException.php new file mode 100755 index 0000000000000..efa8a03bcfe49 --- /dev/null +++ b/setup/src/Magento/Setup/Console/InputValidationException.php @@ -0,0 +1,14 @@ +input = $input; + $this->bufferedOutput = new BufferedOutput($output->getVerbosity(), false, clone $output->getFormatter()); + // Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not. + $currentLength = $this->getTerminalWidth() - (int)(DIRECTORY_SEPARATOR === '\\'); + $this->lineLength = min($currentLength, self::MAX_LINE_LENGTH); + parent::__construct($output); + } + + /** + * Formats a message as a block of text. + * + * @param string|array $messages The message to write in the block + * @param string|null $type The block type (added in [] on first line) + * @param string|null $style The style to apply to the whole block + * @param string $prefix The prefix for the block + * @param bool $padding Whether to add vertical padding + * @return void + */ + public function block( + $messages, + string $type = null, + string $style = null, + string $prefix = ' ', + bool $padding = false + ) { + $messages = is_array($messages) ? array_values($messages) : [$messages]; + $this->autoPrependBlock(); + $this->writeln($this->createBlock($messages, $type, $style, $prefix, $padding)); + $this->newLine(); + } + + /** + * {@inheritdoc} + */ + public function title($message) + { + $this->autoPrependBlock(); + $bar = str_repeat('=', Helper::strlenWithoutDecoration($this->getFormatter(), $message)); + $this->writeln([ + sprintf(' %s', OutputFormatter::escapeTrailingBackslash($message)), + sprintf(' %s', $bar), + ]); + $this->newLine(); + } + + /** + * {@inheritdoc} + */ + public function section($message) + { + $this->autoPrependBlock(); + $bar = str_repeat('-', Helper::strlenWithoutDecoration($this->getFormatter(), $message)); + $this->writeln([ + sprintf(' %s', OutputFormatter::escapeTrailingBackslash($message)), + sprintf(' %s', $bar), + ]); + $this->newLine(); + } + + /** + * {@inheritdoc} + */ + public function listing(array $elements) + { + $this->autoPrependText(); + $elements = array_map(function ($element) { + return sprintf(' * %s', $element); + }, $elements); + + $this->writeln($elements); + $this->newLine(); + } + + /** + * {@inheritdoc} + */ + public function text($message) + { + $this->autoPrependText(); + $messages = is_array($message) ? array_values($message) : [$message]; + foreach ($messages as $singleMessage) { + $this->writeln(sprintf(' %s', $singleMessage)); + } + } + + /** + * Formats a command comment. + * + * @param string|array $message + * @param bool $padding + * @return void + */ + public function comment($message, $padding = false) + { + $this->block($message, null, 'comment', ' ', $padding); + } + + /** + * {@inheritdoc} + */ + public function success($message, $padding = true) + { + $this->block($message, 'SUCCESS', 'fg=black;bg=green', ' ', $padding); + } + + /** + * {@inheritdoc} + */ + public function error($message, $padding = true) + { + $this->block($message, 'ERROR', 'fg=white;bg=red', ' ', $padding); + } + + /** + * {@inheritdoc} + */ + public function warning($message, $padding = true) + { + $this->block($message, 'WARNING', 'fg=black;bg=yellow', ' ', $padding); + } + + /** + * {@inheritdoc} + */ + public function note($message, $padding = false) + { + $this->block($message, 'NOTE', 'fg=yellow', ' ', $padding); + } + + /** + * {@inheritdoc} + */ + public function caution($message, $padding = true) + { + $this->block($message, 'CAUTION', 'fg=black;bg=yellow', ' ! ', $padding); + } + + /** + * {@inheritdoc} + */ + public function table(array $headers, array $rows) + { + $style = clone Table::getStyleDefinition('symfony-style-guide'); + $style->setCellHeaderFormat('%s'); + + $table = new Table($this); + $table->setHeaders($headers); + $table->setRows($rows); + $table->setStyle($style); + + $table->render(); + $this->newLine(); + } + + /** + * {@inheritdoc} + * @throws \Symfony\Component\Console\Exception\InvalidArgumentException + */ + public function ask($question, $default = null, $validator = null, $maxAttempts = null) + { + $question = new Question($question, $default); + $question->setValidator($validator); + $question->setMaxAttempts($maxAttempts); + + return $this->askQuestion($question); + } + + /** + * {@inheritdoc} + * @throws \Symfony\Component\Console\Exception\LogicException + */ + public function askHidden($question, $validator = null) + { + $question = new Question($question); + + $question->setHidden(true); + $question->setValidator($validator); + + return $this->askQuestion($question); + } + + /** + * {@inheritdoc} + */ + public function confirm($question, $default = true) + { + return $this->askQuestion(new ConfirmationQuestion($question, $default)); + } + + /** + * {@inheritdoc} + */ + public function choice($question, array $choices, $default = null) + { + if (null !== $default) { + $values = array_flip($choices); + $default = $values[$default]; + } + + return $this->askQuestion(new ChoiceQuestion($question, $choices, $default)); + } + + /** + * {@inheritdoc} + */ + public function progressStart($max = 0) + { + $this->progressBar = $this->createProgressBar($max); + $this->progressBar->start(); + } + + /** + * {@inheritdoc} + * @throws \Symfony\Component\Console\Exception\LogicException + * @throws \Symfony\Component\Console\Exception\RuntimeException + */ + public function progressAdvance($step = 1) + { + $this->getProgressBar()->advance($step); + } + + /** + * {@inheritdoc} + * @throws \Symfony\Component\Console\Exception\RuntimeException + */ + public function progressFinish() + { + $this->getProgressBar()->finish(); + $this->newLine(2); + $this->progressBar = null; + } + + /** + * {@inheritdoc} + */ + public function createProgressBar($max = 0) + { + $progressBar = parent::createProgressBar($max); + $progressBar->setEmptyBarCharacter(' '); + $progressBar->setProgressCharacter('>'); + $progressBar->setBarCharacter('='); + + return $progressBar; + } + + /** + * Ask user question. + * + * @param Question $question + * + * @return string + */ + public function askQuestion(Question $question) + { + if ($this->input->isInteractive()) { + $this->autoPrependBlock(); + } + + if (!$this->questionHelper) { + $this->questionHelper = new SymfonyQuestionHelper(); + } + + $answer = $this->questionHelper->ask($this->input, $this, $question); + + if ($this->input->isInteractive()) { + $this->newLine(); + $this->bufferedOutput->write(PHP_EOL); + } + + return $answer; + } + + /** + * Ask for an missing argument. + * + * @param string $argument + * @param string $question + * @param string|null $default + * @param callable|null $validator + * @param int|null $maxAttempts + * @param bool $comment + * @param string $commentFormat + * @throws \Symfony\Component\Console\Exception\InvalidArgumentException + */ + public function askForMissingArgument( + string $argument, + string $question, + string $default = null, + callable $validator = null, + int $maxAttempts = null, + bool $comment = null, + string $commentFormat = 'Argument [%s] set to: %s' + ) { + try { + if ($this->input->getArgument($argument) === null) { + $this->input->setArgument($argument, $this->ask($question, $default, $validator, $maxAttempts)); + } + $argumentValue = $this->input->getArgument($argument); + $validated = (is_callable($validator) ? $validator($argumentValue) : $argumentValue); + if ((bool)($comment ?? $this->isDebug())) { + $this->comment(sprintf($commentFormat, $argument, $validated)); + } + } catch (InputValidationException $e) { + $this->error('Validation Error: ' . $e->getMessage()); + $this->askForMissingArgument( + $argument, + $question, + $default, + $validator, + $maxAttempts, + $comment, + $commentFormat + ); + } + } + + /** + * Ask for an missing option. + * + * @param string $option + * @param string $question + * @param string|null $default + * @param callable|null $validator + * @param int|null $maxAttempts + * @param bool $comment + * @param string $commentFormat + * @throws \Symfony\Component\Console\Exception\InvalidArgumentException + */ + public function askForMissingOption( + string $option, + string $question, + string $default = null, + callable $validator = null, + int $maxAttempts = null, + bool $comment = null, + string $commentFormat = 'Option [%s] set to: %s' + ) { + try { + if (null === $this->input->getOption($option)) { + $this->input->setOption($option, $this->ask($question, $default, $validator, $maxAttempts)); + } + $optionValue = $this->input->getOption($option); + $validated = (is_callable($validator) ? $validator($optionValue) : $optionValue); + if ((bool)($comment ?? $this->isDebug())) { + $this->comment(sprintf($commentFormat, $option, $validated)); + } + } catch (InputValidationException $e) { + $this->error('Validation Error: ' . $e->getMessage()); + $this->askForMissingOption( + $option, + $question, + $default, + $validator, + $maxAttempts, + $comment, + $commentFormat + ); + } + } + + /** + * {@inheritdoc} + */ + public function writeln($messages, $type = self::OUTPUT_NORMAL) + { + parent::writeln($messages, $type); + $this->bufferedOutput->writeln($this->reduceBuffer($messages), $type); + } + + /** + * {@inheritdoc} + */ + public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL) + { + parent::write($messages, $newline, $type); + $this->bufferedOutput->write($this->reduceBuffer($messages), $newline, $type); + } + + /** + * {@inheritdoc} + */ + public function newLine($count = 1) + { + parent::newLine($count); + $this->bufferedOutput->write(str_repeat(PHP_EOL, $count)); + } + + /** + * Get progress bar instance. + * + * @return ProgressBar + * @throws RuntimeException in case progress bar hasn't been instantiated yet. + */ + private function getProgressBar() + { + if (!$this->progressBar) { + throw new RuntimeException('The ProgressBar is not started.'); + } + + return $this->progressBar; + } + + /** + * @return int + */ + private function getTerminalWidth() + { + $application = new Application(); + $dimensions = $application->getTerminalDimensions(); + + return $dimensions[0] ?: self::MAX_LINE_LENGTH; + } + + /** + * Add empty line before output element in case there were no empty lines before. + * + * @return void + */ + private function autoPrependBlock() + { + $chars = substr($this->bufferedOutput->fetch(), -2); + if (!isset($chars[0])) { + $this->newLine(); //empty history, so we should start with a new line. + } + //Prepend new line for each non LF chars (This means no blank line was output before) + $this->newLine(2 - substr_count($chars, PHP_EOL)); + } + + /** + * Add empty line before text(listing) output element. + * + * @return void + */ + private function autoPrependText() + { + $fetched = $this->bufferedOutput->fetch(); + //Prepend new line if last char isn't EOL: + if (PHP_EOL !== substr($fetched, -1)) { + $this->newLine(); + } + } + + private function reduceBuffer($messages) + { + // We need to know if the two last chars are PHP_EOL + // Preserve the last 4 chars inserted (PHP_EOL on windows is two chars) in the history buffer + return array_map(function ($value) { + return substr($value, -4); + }, array_merge([$this->bufferedOutput->fetch()], (array)$messages)); + } + + /** + * Build output in block style. + * + * @param array $messages + * @param string|null $type + * @param string|null $style + * @param string $prefix + * @param bool $padding + * @return array + */ + private function createBlock( + array $messages, + string $type = null, + string $style = null, + string $prefix = ' ', + bool $padding = false + ) { + $indentLength = 0; + $prefixLength = Helper::strlenWithoutDecoration($this->getFormatter(), $prefix); + if (null !== $type) { + $type = sprintf('[%s] ', $type); + $indentLength = strlen($type); + $lineIndentation = str_repeat(' ', $indentLength); + } + $lines = $this->getBlockLines($messages, $prefixLength, $indentLength); + $firstLineIndex = 0; + if ($padding && $this->isDecorated()) { + $firstLineIndex = 1; + array_unshift($lines, ''); + $lines[] = ''; + } + foreach ($lines as $i => &$line) { + if (null !== $type) { + $line = $firstLineIndex === $i ? $type . $line : $lineIndentation . $line; + } + $line = $prefix . $line; + $multiplier = $this->lineLength - Helper::strlenWithoutDecoration($this->getFormatter(), $line); + $line .= str_repeat(' ', $multiplier); + if ($style) { + $line = sprintf('<%s>%s', $style, $line); + } + } + + return $lines; + } + + /** + * Wrap and add new lines for each element. + * + * @param array $messages + * @param int $prefixLength + * @param int $indentLength + * @return array + */ + private function getBlockLines( + array $messages, + int $prefixLength, + int $indentLength + ) { + $lines = [[]]; + foreach ($messages as $key => $message) { + $message = OutputFormatter::escape($message); + $wordwrap = wordwrap($message, $this->lineLength - $prefixLength - $indentLength, PHP_EOL, true); + $lines[] = explode(PHP_EOL, $wordwrap); + if (count($messages) > 1 && $key < count($messages) - 1) { + $lines[][] = ''; + } + } + $lines = array_merge(...$lines); + + return $lines; + } +} diff --git a/setup/src/Magento/Setup/Console/Style/MagentoStyleInterface.php b/setup/src/Magento/Setup/Console/Style/MagentoStyleInterface.php new file mode 100755 index 0000000000000..a7aba31549699 --- /dev/null +++ b/setup/src/Magento/Setup/Console/Style/MagentoStyleInterface.php @@ -0,0 +1,17 @@ + 'foo'], new InputDefinition([new InputArgument('name')])); + $this->testOutput = new TestOutput(); + $this->magentoStyle = new MagentoStyle($input, $this->testOutput); + } + + /** + * Test style decorator will output block with correct style. + * + * @return void + */ + public function testBlockStyle() + { + $this->magentoStyle->block( + ['test first message', 'test second message'], + 'testBlockType', + 'testBlockStyle', + 'testBlockPrefix' + ); + // @codingStandardsIgnoreStart + $expected = PHP_EOL . PHP_EOL . PHP_EOL . + '\testBlockPrefix\[testBlockType\] test first message\s+' + . PHP_EOL . '\testBlockPrefix\s+' + . PHP_EOL . '\testBlockPrefix \s+ test second message\s+' + . PHP_EOL . PHP_EOL; + // @codingStandardsIgnoreEnd + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Block does not match output'); + } + + /** + * Test style decorator will add title with correct style. + * + * @return void + */ + public function testTitleStyle() + { + $this->magentoStyle->title('My Title'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . ' My Title' . PHP_EOL . ' ========' . PHP_EOL . PHP_EOL; + $this->assertEquals($expected, $this->testOutput->output, 'Title does not match output'); + } + + /** + * Test style decorator will output section with correct style. + * + * @return void + */ + public function testSectionStyle() + { + $this->magentoStyle->section('My Section'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . ' My Section' . PHP_EOL . ' ----------' . PHP_EOL . PHP_EOL; + $this->assertEquals($expected, $this->testOutput->output, 'Section does not match output'); + } + + /** + * Test style decorator will output listing with proper style. + * + * @return void + */ + public function testListingStyle() + { + $this->magentoStyle->listing(['test first element', 'test second element']); + $expected = PHP_EOL . ' * test first element' . PHP_EOL . ' * test second element' . PHP_EOL . PHP_EOL; + $this->assertEquals($expected, $this->testOutput->output, 'Listing does not match output'); + } + + /** + * Test style decorator will output text with proper style. + * + * @return void + */ + public function testTextStyle() + { + $this->magentoStyle->text('test message'); + $expected = PHP_EOL . ' test message' . PHP_EOL; + + $this->assertEquals($expected, $this->testOutput->output, 'Text does not match output'); + } + + /** + * Test style decorator will output comment with proper style. + * + * @return void + */ + public function testCommentStyle() + { + $this->magentoStyle->comment('test comment'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . '\s+test comment\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Comment does not match output'); + } + + /** + * Test style decorator will output success message with proper style. + * + * @return void + */ + public function testSuccessStyle() + { + $this->magentoStyle->success('test success message'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . ' \[SUCCESS\] test success message\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Success message does not match output'); + } + + /** + * Test style decorator will output error message with proper style. + * + * @return void + */ + public function testErrorStyle() + { + $this->magentoStyle->error('test error message'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . '\s+\[ERROR\] test error message\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Error message does not match output'); + } + + /** + * Test style decorator will output warning message with proper style. + * + * @return void + */ + public function testWarningStyle() + { + $this->magentoStyle->warning('test warning message'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . '\s+\[WARNING\] test warning message\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Warning message does not match output'); + } + + /** + * Test style decorator will output note message with proper style. + * + * @return void + */ + public function testNoteStyle() + { + $this->magentoStyle->note('test note message'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . '\s+\[NOTE\] test note message\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Note message does not match output'); + } + + /** + * Test style decorator will output caution message with proper style. + * + * @return void + */ + public function testCautionStyle() + { + $this->magentoStyle->caution('test caution message'); + $expected = PHP_EOL . PHP_EOL . PHP_EOL . '\s+! \[CAUTION\] test caution message\s+' . PHP_EOL . PHP_EOL; + $this->assertRegExp('/' . $expected . '/', $this->testOutput->output, 'Caution message does not match output'); + } + + /** + * Test style decorator will output table with proper style. + * + * @return void + */ + public function testTableStyle() + { + $headers = [ + [new TableCell('Main table title', ['colspan' => 2])], + ['testHeader1', 'testHeader2', 'testHeader3'], + ]; + $rows = [ + [ + 'testValue1', + 'testValue2', + new TableCell('testValue3', ['rowspan' => 2]), + ], + ['testValue4', 'testValue5'], + ]; + $this->magentoStyle->table($headers, $rows); + $expected = ' ------------- ------------- ------------- ' . PHP_EOL . + ' Main table title ' . PHP_EOL . + ' ------------- ------------- ------------- ' . PHP_EOL . + ' testHeader1 testHeader2 testHeader3 ' . PHP_EOL . + ' ------------- ------------- ------------- ' . PHP_EOL . + ' testValue1 testValue2 testValue3 ' . PHP_EOL . + ' testValue4 testValue5 ' . PHP_EOL . + ' ------------- ------------- ------------- ' . PHP_EOL . PHP_EOL; + + $this->assertEquals($expected, $this->testOutput->output, 'Table does not match output'); + } + + /** + * @return void + */ + public function testAsk() + { + $objectManager = new ObjectManager($this); + $formatter = $this->getMockBuilder(OutputFormatter::class) + ->disableOriginalConstructor() + ->getMock(); + $input = $this->getMockBuilder(InputInterface::class) + ->setMethods(['isInteractive']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $input->expects($this->exactly(2)) + ->method('isInteractive') + ->willReturn(false); + $output = $this->getMockBuilder(OutputInterface::class) + ->setMethods(['getVerbosity', 'getFormatter']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $output->expects($this->once()) + ->method('getVerbosity') + ->willReturn(32); + $output->expects($this->once()) + ->method('getFormatter') + ->willReturn($formatter); + $magentoStyle = $objectManager->getObject( + MagentoStyle::class, + [ + 'input' => $input, + 'output' => $output, + ] + ); + $questionHelper = $this->getMockBuilder(SymfonyQuestionHelper::class) + ->disableOriginalConstructor() + ->getMock(); + $questionHelper->expects($this->once()) + ->method('ask') + ->willReturn('test Answer'); + $objectManager->setBackwardCompatibleProperty($magentoStyle, 'questionHelper', $questionHelper); + + $this->assertEquals( + 'test Answer', + $magentoStyle->ask('test question?', 'test default') + ); + } + + /** + * Test style decorator will output progress with proper style. + * + * @return void + */ + public function testProgress() + { + $this->magentoStyle->progressStart(2); + $this->magentoStyle->progressAdvance(3); + $this->magentoStyle->progressFinish(); + $expected = ' 0/2 [> ] 0%' . PHP_EOL . + ' 3/3 [============================] 100%' . PHP_EOL . PHP_EOL; + $this->assertEquals($expected, $this->testOutput->output, 'Progress does not match output'); + } +} diff --git a/setup/src/Magento/Setup/Test/Unit/Console/Style/TestOutput.php b/setup/src/Magento/Setup/Test/Unit/Console/Style/TestOutput.php new file mode 100644 index 0000000000000..1407e5ed183e4 --- /dev/null +++ b/setup/src/Magento/Setup/Test/Unit/Console/Style/TestOutput.php @@ -0,0 +1,27 @@ +output = ''; + } + + protected function doWrite($message, $newline) + { + $this->output .= $message . ($newline ? "\n" : ''); + } +}