diff --git a/.github/assets/ExamplesOutput-vvv.png b/.github/assets/ExamplesOutput-vvv.png deleted file mode 100644 index 8081c6a..0000000 Binary files a/.github/assets/ExamplesOutput-vvv.png and /dev/null differ diff --git a/.github/assets/ExamplesProfile--timestamp.png b/.github/assets/ExamplesProfile--timestamp.png deleted file mode 100644 index 6bcbc97..0000000 Binary files a/.github/assets/ExamplesProfile--timestamp.png and /dev/null differ diff --git a/.github/assets/helpers.gif b/.github/assets/helpers.gif new file mode 100644 index 0000000..da8f8d6 Binary files /dev/null and b/.github/assets/helpers.gif differ diff --git a/.github/assets/logs-cron.png b/.github/assets/logs-cron.png new file mode 100644 index 0000000..b861a75 Binary files /dev/null and b/.github/assets/logs-cron.png differ diff --git a/.github/assets/logs-logstash-exception.png b/.github/assets/logs-logstash-exception.png new file mode 100644 index 0000000..d65a326 Binary files /dev/null and b/.github/assets/logs-logstash-exception.png differ diff --git a/.github/assets/logs-simple.png b/.github/assets/logs-simple.png new file mode 100644 index 0000000..4550117 Binary files /dev/null and b/.github/assets/logs-simple.png differ diff --git a/.github/assets/output-full-example.png b/.github/assets/output-full-example.png new file mode 100644 index 0000000..d6500b3 Binary files /dev/null and b/.github/assets/output-full-example.png differ diff --git a/.github/assets/output-styles.gif b/.github/assets/output-styles.gif new file mode 100644 index 0000000..fd103f4 Binary files /dev/null and b/.github/assets/output-styles.gif differ diff --git a/.github/assets/ExamplesProfile.png b/.github/assets/profiling.png similarity index 100% rename from .github/assets/ExamplesProfile.png rename to .github/assets/profiling.png diff --git a/.github/assets/progress-default-example.gif b/.github/assets/progress-default-example.gif new file mode 100644 index 0000000..3de5cf5 Binary files /dev/null and b/.github/assets/progress-default-example.gif differ diff --git a/.github/assets/progress-full-example.gif b/.github/assets/progress-full-example.gif new file mode 100644 index 0000000..533e0fe Binary files /dev/null and b/.github/assets/progress-full-example.gif differ diff --git a/.phan.php b/.phan.php index ca50127..79f5d3c 100644 --- a/.phan.php +++ b/.phan.php @@ -24,7 +24,10 @@ 'vendor/jbzoo/event', 'vendor/symfony/console', + 'vendor/symfony/console/Command', 'vendor/symfony/process', 'vendor/bluepsyduck/symfony-process-manager/src', + 'vendor/psr/log', + 'vendor/monolog/monolog', ], ]); diff --git a/Makefile b/Makefile index d1139a2..146abfc 100644 --- a/Makefile +++ b/Makefile @@ -26,3 +26,8 @@ test-all: ##@Project Run all project tests at once @make test @make codestyle @make codestyle PATH_SRC=./demo + + +toc: ##@Project Generate table of contents + @echo "Generate table of contents" + @gh-md-toc --insert --no-backup --skip-header ./README.md diff --git a/README.md b/README.md index 5abcd2e..321b4ff 100644 --- a/README.md +++ b/README.md @@ -4,38 +4,118 @@ [![Stable Version](https://poser.pugx.org/jbzoo/cli/version)](https://packagist.org/packages/jbzoo/cli/) [![Total Downloads](https://poser.pugx.org/jbzoo/cli/downloads)](https://packagist.org/packages/jbzoo/cli/stats) [![Dependents](https://poser.pugx.org/jbzoo/cli/dependents)](https://packagist.org/packages/jbzoo/cli/dependents?order_by=downloads) [![GitHub License](https://img.shields.io/github/license/jbzoo/cli)](https://github.com/JBZoo/Cli/blob/master/LICENSE) -The library greatly extends the functionality of [Symfony/Console](https://symfony.com/doc/current/components/console.html) and helps make creating new console utilities in PHP quicker and easier. - - * Improved progress bar with a new template and additional information. See [ExamplesProgressBar.php](demo/Commands/ExamplesProgressBar.php). - * Convert option values to a strict variable type. See [ExamplesOptionsStrictTypes.php](demo/Commands/ExamplesOptionsStrictTypes.php). - * New built-in styles and colors for text output. See [ExamplesStyles.php](demo/Commands/ExamplesStyles.php). - * A powerful alias `$this->_($messages, $level)` instead of `output->wrileln()`. See [ExamplesOutput.php](demo/Commands/ExamplesOutput.php). - * Display timing and memory usage information with `--profile` option. - * Show timestamp at the beginning of each message with `--timestamp` option. - * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors`. - * None-zero exit code on any StdErr message with `--non-zero-on-error` option. - * For any errors messages application will use StdOut instead of StdErr `--stdout-only` option (It's on your own risk!). - * Disable progress bar animation for logs with `--no-progress` option. - * Shortcut for crontab `--cron`. It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv`. + + * [Why?](#why) + * [Live Demo](#live-demo) + * [Output regular messages](#output-regular-messages) + * [Progress Bar Demo](#progress-bar-demo) + * [Quck Start - Build your first CLI App](#quck-start---build-your-first-cli-app) + * [Installing](#installing) + * [File Structure](#file-structure) + * [Composer file](#composer-file) + * [Binary file](#binary-file) + * [Simple CLI Action](#simple-cli-action) + * [Built-in Functionality](#built-in-functionality) + * [Sanitize input variables](#sanitize-input-variables) + * [Rendering text in different colors and styles](#rendering-text-in-different-colors-and-styles) + * [Verbosity Levels](#verbosity-levels) + * [Memory and time profiling](#memory-and-time-profiling) + * [Progress Bar](#progress-bar) + * [Simple example](#simple-example) + * [Advanced usage](#advanced-usage) + * [Helper Functions](#helper-functions) + * [Regualar question](#regualar-question) + * [Ask user's password](#ask-users-password) + * [Ask user to select the option](#ask-user-to-select-the-option) + * [Represent a yes/no question](#represent-a-yesno-question) + * [Rendering key=>value list](#rendering-keyvalue-list) + * [Easy logging](#easy-logging) + * [Simple log](#simple-log) + * [Crontab](#crontab) + * [Elatcisearch / Logstash (ELK)](#elatcisearch--logstash-elk) + * [Multi processing](#multi-processing) + * [Tips & Tricks](#tips--tricks) + * [Contributing](#contributing) + * [Useful projects and links](#useful-projects-and-links) + * [License](#license) + * [See Also](#see-also) + + + + + + +## Why? + +The library greatly extends the functionality of CLI App and helps make creating new console utilities in PHP quicker and easier. Here's a summary of why this library is essential: + + * **Enhanced Functionality** + * The library supercharges [Symfony/Console](https://symfony.com/doc/current/components/console.html), facilitating a more streamlined development of console utilities. + + * **Progress Bar Improvements** + * Developers gain a refined progress bar suited for loop actions and enhanced with debugging information. This makes tracking task progress and diagnosing issues a breeze. See [DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and see [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). + * `$this->_($messages, $level, $context)` as part of CliCommand instead of Symfony/Console `$output->writeln()`. + * `cli($messages, $level, $context)` as alias for different classes. + * `$this->progressBar(iterable|int $listOrMax, \Closure $callback, string $title = '')` as part of CliCommand instead of Symfony/Console ProgressBar. + + * **Strict Type Conversion** + * One notable feature allows for the strict conversion of option values, ensuring data integrity and reducing runtime errors. See [DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). + * Built-in validations for list of values. See [Sanitize input variables](#sanitize-input-variables). + + * **Styling and Output Customization** + * With built-in styles and color schemes, developers can make their console outputs more readable and visually appealing. See [DemoStyles.php](demo/Commands/DemoStyles.php). + + * **Message Aliases** + * The library introduces powerful aliases for message outputs, allowing for concise and consistent command calls. This is especially helpful in maintaining clean code. + + * **Advanced Options** + * Features such as profiling for performance, timestamping, error muting, and specialized output modes (like cron and logstash modes) empower developers to refine their console outputs and diagnostics according to their specific needs. + * Display timing and memory usage information with `--profile` (`-X`) option. + * Show timestamp at the beginning of each message with `--timestamp` (`-T`) option. + * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors` (`-M`). + * None-zero exit code on any StdErr message with `--non-zero-on-error` (`-Z`) option. + * For any errors messages application will use StdOut instead of StdErr `--stdout-only` (`-1`) option (It's on your own risk!). + * Disable progress bar animation for logs with `--no-progress` (`-P`) option. + + * **Versatile Output Modes** + * The library provides different output formats catering to various use cases. Whether you're focusing on user-friendly text, logs, or integration with tools like ELK Stack, there's an output mode tailored for you. + * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. + * `--output-mode=cron` (`-Ocron`). It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv --no-ansi`. + * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it means `--stdout-only --no-progress -vv`. + + * **Bonuses** + * There is a [multiprocess](#multi-processing) mode (please don't confuse it with multithreading) to speed up work with a monotonous dataset. + * Helper functions for [user input](#helper-functions) in interactive mode. + + ## Live Demo -[![asciicast](https://asciinema.org/a/486674.svg)](https://asciinema.org/a/486674) +### Output regular messages + +[![asciicast](https://asciinema.org/a/601633.svg)](https://asciinema.org/a/601633?autoplay=1&startAt=4) + + + +### Progress Bar Demo +[![asciicast](https://asciinema.org/a/601621.svg)](https://asciinema.org/a/601621?autoplay=1&startAt=2) -## Installing + +## Quck Start - Build your first CLI App + +### Installing ```sh composer require jbzoo/cli ``` - -## Usage Example - The simplest CLI application has the following file structure. See the [Demo App](demo) for more details. + +### File Structure ``` /path/to/app/ my-app # Binrary file (See below) @@ -47,6 +127,8 @@ The simplest CLI application has the following file structure. See the [Demo App ``` +### Composer file + [./demo/composer.json](demo/composer.json)
@@ -61,12 +143,8 @@ The simplest CLI application has the following file structure. See the [Demo App "keywords" : ["cli", "application", "example"], "require" : { - "php" : ">=7.4", - "jbzoo/cli" : "^2.0.0" - }, - - "require-dev" : { - "roave/security-advisories" : "dev-latest" + "php" : ">=8.1", + "jbzoo/cli" : "^7.1.0" }, "autoload" : { @@ -80,6 +158,8 @@ The simplest CLI application has the following file structure. See the [Demo App
+### Binary file + Binary file: [demo/my-app](demo/my-app)
@@ -87,52 +167,52 @@ Binary file: [demo/my-app](demo/my-app) ```php #!/usr/bin/env php - setLogo(implode(PHP_EOL, [ - " __ __ _____ _ ", - " | \/ | / ____| | | /\ ", - " | \ / |_ _ | | ___ _ __ ___ ___ | | ___ / \ _ __ _ __ ", - " | |\/| | | | | | | / _ \| '_ \/ __|/ _ \| |/ _ \ / /\ \ | '_ \| '_ \ ", - " | | | | |_| | | |___| (_) | | | \__ \ (_) | | __/ / ____ \| |_) | |_) |", - " |_| |_|\__, | \_____\___/|_| |_|___/\___/|_|\___| /_/ \_\ .__/| .__/ ", - " __/ | | | | | ", - " |___/ |_| |_| ", - ])); - - // Scan directory to find commands. - // * It doesn't work recursively! - // * They must be inherited from the class \JBZoo\Cli\CliCommand - $application->registerCommandsByPath(__DIR__ . '/Commands', __NAMESPACE__); - - // Action name by default (if there is no arguments) - $application->setDefaultCommand('list'); - - // Run application - $application->run(); +setLogo( + <<<'EOF' + __ __ _____ _ + | \/ | / ____| | | /\ + | \ / |_ _ | | ___ _ __ ___ ___ | | ___ / \ _ __ _ __ + | |\/| | | | | | | / _ \| '_ \/ __|/ _ \| |/ _ \ / /\ \ | '_ \| '_ \ + | | | | |_| | | |___| (_) | | | \__ \ (_) | | __/ / ____ \| |_) | |_) | + |_| |_|\__, | \_____\___/|_| |_|___/\___/|_|\___| /_/ \_\ .__/| .__/ + __/ | | | | | + |___/ |_| |_| + EOF, +); + +// Scan directory to find commands. +// * It doesn't work recursively! +// * They must be inherited from the class \JBZoo\Cli\CliCommand +$application->registerCommandsByPath(__DIR__ . '/Commands', __NAMESPACE__); + +// Optional. Action name by default (if there is no arguments) +$application->setDefaultCommand('list'); + +// Run application +$application->run(); ```
-The simplest CLI action: [./demo/Commands/Simple.php](demo/Commands/Simple.php) +### Simple CLI Action + +The simplest CLI action: [./demo/Commands/DemoSimple.php](demo/Commands/DemoSimple.php)
See Details @@ -176,45 +256,50 @@ The simplest CLI action: [./demo/Commands/Simple.php](demo/Commands/Simple.php) ### Sanitize input variables -As live-demo take a look at demo application - [./demo/Commands/ExamplesOptionsStrictTypes.php](demo/Commands/ExamplesOptionsStrictTypes.php). +As live-demo take a look at demo application - [./demo/Commands/DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). -Try to launch `./my-app examples:options-strict-types`. +Try to launch `./my-app options-strict-types`. ```php // If the option has `InputOption::VALUE_NONE` it returns true/false. -// --flag-name -$value = $this->getOpt('flag-name'); // `$value === true` +// --option-name +$value = $this->getOpt('option-name'); // `$value === true` -// --flag-name=" 123.6 " -$value = $this->getOpt('flag-name'); // Returns the value AS-IS. `$value === " 123.6 "` +// --option-name=" 123.6 " +$value = $this->getOpt('option-name'); // Returns the value AS-IS. `$value === " 123.6 "` -// --flag-name=" 123.6 " -$value = $this->getOptBool('flag-name'); // Converts an input variable to boolean. `$value === true` +// --option-name=" 123.6 " +$value = $this->getOptBool('option-name'); // Converts an input variable to boolean. `$value === true` -// --flag-name=" 123.6 " -$value = $this->getOptInt('flag-name'); // Converts an input variable to integer. `$value === 123` +// --option-name=" 123.6 " +$value = $this->getOptInt('option-name'); // Converts an input variable to integer. `$value === 123` +$value = $this->getOptInt('option-name', 42, [1, 2, 42]); // Strict comparing with allowed values -// --flag-name=" 123.6 " -$value = $this->getOptFloat('flag-name'); // Converts an input variable to float. `$value === 123.6` +// --option-name=" 123.6 " +$value = $this->getOptFloat('option-name'); // Converts an input variable to float. `$value === 123.6` +$value = $this->getOptFloat('option-name', 1.0, [1.0, 2.0, 3.0]); // Strict comparing with allowed values -// --flag-name=" 123.6 " -$value = $this->getOptString('flag-name'); // Converts an input variable to trimmed string. `$value === "123.6"` +// --option-name=" 123.6 " +$value = $this->getOptString('option-name'); // Converts an input variable to trimmed string. `$value === "123.6"` +$value = $this->getOptString('option-name', 'default', ['default', 'mini', 'full']); // Strict comparing with allowed values -// --flag-name=123.6 -$value = $this->getOptArray('flag-name'); // Converts an input variable to trimmed string. `$value === ["123.6"]` +// --option-name=123.6 +$value = $this->getOptArray('option-name'); // Converts an input variable to trimmed string. `$value === ["123.6"]` -// --flag-name="15 July 2021 13:48:00" -$value = $this->getOptDatetime('flag-name'); // Converts an input variable to \DateTimeImmutable object. +// --option-name="15 July 2021 13:48:00" +$value = $this->getOptDatetime('option-name'); // Converts an input variable to \DateTimeImmutable object. // Use standard input as input variable. -// Example. `echo " Qwerty 123 " | php ./my-app examples:agruments` -$value = self::getStdIn(); // Reads StdIn as string value. `$value === " Qwerty 123 \n"` +// Example. `echo " Qwerty 123 " | php ./my-app agruments` +$value = self::getStdIn(); // Reads StdIn as string value. `$value === " Qwerty 123 \n"` ``` ### Rendering text in different colors and styles +output-styles + There are list of predefined colors ```html @@ -267,74 +352,111 @@ And predefined shortcuts for standard styles of Symfony Console Console commands have different verbosity levels, which determine the messages displayed in their output. -As live-demo take a look at demo application - [./demo/Commands/ExamplesOutput.php](demo/Commands/ExamplesOutput.php). You can see [Demo video](https://asciinema.org/a/486674). +As live-demo take a look at demo application - [./demo/Commands/ExamplesOutput.php](demo/Commands/DemoOutput.php). You can see [Demo video](https://asciinema.org/a/486674). Example of usage of verbosity levels -```php -// There two strictly recommended output ways: -$this->_($messages, $verboseLevel); // Prints a message to the output in the command class which inherits from the class \JBZoo\Cli\CliCommand -cli($messages, $verboseLevel); // This is global alias function of `$this->_(...)`. It's nice to have it if you want to display a text from not CliCommand class. +![output-full-example](.github/assets/output-full-example.png) -// * `$messages` can be an array of strings or a string. Array of strings will be imploded with new line. -// * `$verboseLevel` is one of value form the class \JBZoo\Cli\OutLvl::* +```php +// There two strictly(!) recommended output ways: + +/** + * Prints a message to the output in the command class which inherits from the class \JBZoo\Cli\CliCommand + * + * @param string|string[] $messages Output message(s). Can be an array of strings or a string. Array of strings will be imploded with new line. + * @param string $verboseLevel is one of value form the class \JBZoo\Cli\OutLvl::* + * @param string $context is array of extra info. Will be serialized to JSON and displayed in the end of the message. + */ +$this->_($messages, $verboseLevel, $context); + +/** + * This is global alias function of `$this->_(...)`. + * It's nice to have it if you want to display a text from not CliCommand class. + */ +JBZoo\Cli\cli($messages, $verboseLevel, $context); + ``` ```bash # Do not output any message -./my-app examples:output -q -./my-app examples:output --quiet +./my-app output -q +./my-app output --quiet # Normal behavior, no option required. Only the most useful messages. -./my-app examples:output +./my-app output # Increase verbosity of messages -./my-app examples:output -v +./my-app output -v # Display also the informative non essential messages -./my-app examples:output -vv +./my-app output -vv # Display all messages (useful to debug errors) -./my-app examples:output -vvv +./my-app output -vvv ``` -As result, you will see -![ExamplesOutput -vvv](.github/assets/ExamplesOutput-vvv.png) +### Memory and time profiling +As live-demo take a look at demo application - [./demo/Commands/DemoProfile.php](demo/Commands/DemoProfile.php). -### Memory and time profiling +Try to launch `./my-app profile --profile`. -As live-demo take a look at demo application - [./demo/Commands/ExamplesProfile.php](demo/Commands/ExamplesProfile.php). +![profiling](.github/assets/profiling.png) -Try to launch `./my-app examples:profile --profile`. -![ExamplesProfile](.github/assets/ExamplesProfile.png) +## Progress Bar +As live-demo take a look at demo application - [./demo/Commands/DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). -### Easy logging +You can consider this as a substitute for the long cycles you want to profile. -No need to bother with the logging setup as Symfony/Console suggests. Just add the `--timestamp` flag and save the output to a file. Especially, this is very handy for saving cron logs. +Keep in mind that there is an additional overhead for memory and runtime to calculate all the extra debugging information in `--verbose` mode. -```bash -./my-app examples:profile --timestamp >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log + + +### Simple example + +![progress-default-example](.github/assets/progress-default-example.gif) + +```php +$this->progressBar(5, function (): void { + // Some code in loop +}); ``` -![ExamplesProfile--timestamp](.github/assets/ExamplesProfile--timestamp.png) +### Advanced usage + +![progress-full-example](.github/assets/progress-full-example.gif) + +```php +$this->progressBar($arrayOfSomething, function ($value, $key, $step) { + // Some code in loop + + if ($step === 3) { + throw new ExceptionBreak("Something went wrong with \$value={$value}. Stop the loop!"); + } + + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; +}, 'Custom messages based on callback arguments', $throwBatchException); +``` -### Helper Functions +## Helper Functions -As live-demo take a look at demo application - [./demo/Commands/ExamplesHelpers.php](demo/Commands/ExamplesHelpers.php). +As live-demo take a look at demo application - [./demo/Commands/DemoHelpers.php](demo/Commands/DemoHelpers.php). -Try to launch `./my-app examples:helpers`. +Try to launch `./my-app helpers`. JBZoo/Cli uses [Symfony Question Helper](https://symfony.com/doc/current/components/console/helpers/questionhelper.html) as base for aliases. -#### Regualar question +![helpers](.github/assets/helpers.gif) + +### Regualar question Ask any custom question and wait for a user's input. There is an option to set a default value. @@ -343,7 +465,7 @@ $yourName = $this->ask("What's your name?", 'Default Noname'); $this->_("Your name is \"{$yourName}\""); ``` -#### Ask user's password +### Ask user's password Ask a question and hide the response. This is particularly convenient for passwords. There is an option to set a random value as default value. @@ -353,7 +475,7 @@ $yourSecret = $this->askPassword("New password?", true); $this->_("Your secret is \"{$yourSecret}\""); ``` -#### Ask user to select the option +### Ask user to select the option If you have a predefined set of answers the user can choose from, you could use a method `askOption` which makes sure that the user can only enter a valid string from a predefined list. @@ -364,7 +486,7 @@ $selectedColor = $this->askOption("What's your favorite color?", ['Red', 'Blue', $this->_("Selected color is {$selectedColor}"); ``` -#### Represent a yes/no question +### Represent a yes/no question Suppose you want to confirm an action before actually executing it. Add the following to your command. @@ -373,7 +495,7 @@ $isConfirmed = $this->confirmation('Are you ready to execute the script?'); $this->_("Is confirmed: " . ($isConfirmed ? 'Yes' : 'No')); ``` -#### Rendering key=>value list +### Rendering key=>value list If you need to show an aligned list, use the following code. @@ -396,6 +518,79 @@ $this->_(CliRender::list([ ``` +## Easy logging + +### Simple log + +```bash +./my-app output --timestamp >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + +![logs-simple](.github/assets/logs-simple.png) + + + +### Crontab + +Just add the `--output-mode=cron` flag and save the output to a file. Especially, this is very handy for saving logs for Crontab. + +```bash +./my-app output --output-mode=cron >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + +![logs-cron](.github/assets/logs-cron.png) + + + +### Elatcisearch / Logstash (ELK) + +Just add the `--output-mode=logstash` flag and save the output to a file. Especially, this is very handy for saving logs for ELK Stack. + +```bash +./my-app output --output-mode=logstash >> /path/to/logstash/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + +![logs-logstash-exception](.github/assets/logs-logstash-exception.png) + + +## Multi processing + +There is a multiprocess mode (please don't confuse it with multithreading) to speed up work with a monotonous dataset. Basically, `JBZoo\Cli` will start a separate child process (not a thread!) for each dataset and wait for all of them to execute (like a Promise). This is how you get acceleration, which will depend on the power of your server and the data processing algorithm. + +You will see a simple progress bar, but you won't be able to profile and log nicely, as it works for normal mode. + +You can find examples here + * [./tests/TestApp/Commands/TestSleepMulti.php](tests/TestApp/Commands/TestSleepMulti.php) - Parent command + * [./tests/TestApp/Commands/TestSleep.php](tests/TestApp/Commands/TestSleep.php) - Child command + + +Notes: + * Pay attention on the method `executeOneProcess()` and `getListOfChildIds()` which are used to manage child processes. They are inherited from `CliCommandMultiProc` class. + * Optimal number of child processes is `Number of CPU cores - 1` . You can override this value by setting cli options. See them here [./src/CliCommandMultiProc.php](src/CliCommandMultiProc.php). + * Be really careful with concurrency. It's not easy to debug. Try to use `-vvv` option to see all errors and warnings. + + +## Tips & Tricks + + * Define constant `\JBZOO_CLI_NO_PREDEFINED_SHORTCUTS=true` to disable all predefined shortcuts in options if you have conflicts. + * Use class `\JBZoo\Cli\Codes` to get all available exit codes. + + +## Contributing + +```shell +# Fork the repo and build project +make update + +# Make your local changes + +# Run all tests and check code style +make test-all + +# Create your pull request and check all tests on GithubActions page +``` + + ## Useful projects and links @@ -406,6 +601,7 @@ $this->_(CliRender::list([ * [splitbrain/php-cli - Lightweight and no dependencies CLI framework](https://packagist.org/packages/splitbrain/php-cli) * [thephpleague/climate - Allows you to easily output colored text, special formats](https://github.com/thephpleague/climate) * [Exit Codes With Special Meanings](https://tldp.org/LDP/abs/html/exitcodes.html) +* [How to redirect standard (stderr) error in bash](https://www.cyberciti.biz/faq/how-to-redirect-standard-error-in-bash/) diff --git a/composer.json b/composer.json index 370825b..2007ddd 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,12 @@ "cli", "command-line", "console-application", + "cron", + "crontab", + "elk", + "elk-stack", + "elastic", + "logstash", "symfony", "process", "symfony-console", @@ -30,13 +36,14 @@ "require" : { "php" : "^8.1", - "jbzoo/utils" : "^7.0", + "jbzoo/utils" : "^7.1", "jbzoo/event" : "^7.0", "symfony/process" : ">=5.4", "symfony/console" : ">=5.4", "symfony/lock" : ">=5.4", - "bluepsyduck/symfony-process-manager" : ">=1.3.3" + "bluepsyduck/symfony-process-manager" : ">=1.3.3", + "monolog/monolog" : "^3.4" }, "require-dev" : { diff --git a/demo/Commands/ExamplesHelpers.php b/demo/Commands/DemoHelpers.php similarity index 91% rename from demo/Commands/ExamplesHelpers.php rename to demo/Commands/DemoHelpers.php index c1e22bf..8d6932c 100644 --- a/demo/Commands/ExamplesHelpers.php +++ b/demo/Commands/DemoHelpers.php @@ -20,20 +20,17 @@ use function JBZoo\Cli\cli; -class ExamplesHelpers extends CliCommand +class DemoHelpers extends CliCommand { protected function configure(): void { $this - ->setName('examples:helpers') + ->setName('helpers') ->setDescription('Examples of new CLI helpers'); parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { $yourName = $this->ask("What's your name?", 'idk'); diff --git a/demo/Commands/ExamplesOptionsStrictTypes.php b/demo/Commands/DemoOptionsStrictTypes.php similarity index 95% rename from demo/Commands/ExamplesOptionsStrictTypes.php rename to demo/Commands/DemoOptionsStrictTypes.php index dc91a09..a52d1be 100644 --- a/demo/Commands/ExamplesOptionsStrictTypes.php +++ b/demo/Commands/DemoOptionsStrictTypes.php @@ -19,17 +19,14 @@ use JBZoo\Cli\CliCommand; use Symfony\Component\Console\Input\InputOption; -class ExamplesOptionsStrictTypes extends CliCommand +class DemoOptionsStrictTypes extends CliCommand { protected function configure(): void { $this - ->setName('examples:options-strict-types') + ->setName('options-strict-types') ->setDescription('Show description of command.') - ->setHelp( - "Full description and usage of command.\n" . - 'You can use severla lines.', - ) + ->setHelp("Full description and usage of command.\nYou can use severla lines.") // None ->addOption('opt', 'o', InputOption::VALUE_NONE, 'Just a boolean flag') @@ -88,9 +85,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { // //////////////////////////////////////// Just a boolean flag @@ -157,7 +151,7 @@ protected function executeAction(): int // ./my-app examples:agruments -aQwerty -aAsd $this->getOpt('opt-array-req-default'); // 'Asd' - $input = $this->helper->getInput(); + $input = $this->outputMode->getInput(); // //////////////////////////////////////// Arguments // ./my-app examples:agruments $input->getArgument('arg-req'); // null diff --git a/demo/Commands/ExamplesOutput.php b/demo/Commands/DemoOutput.php similarity index 97% rename from demo/Commands/ExamplesOutput.php rename to demo/Commands/DemoOutput.php index 1ed8038..88aa5e8 100644 --- a/demo/Commands/ExamplesOutput.php +++ b/demo/Commands/DemoOutput.php @@ -22,21 +22,18 @@ use function JBZoo\Cli\cli; -class ExamplesOutput extends CliCommand +class DemoOutput extends CliCommand { protected function configure(): void { $this - ->setName('examples:output') + ->setName('output') ->setDescription('Examples of output and error reporting') ->addOption('throw-custom-exception', 'e', InputOption::VALUE_NONE, 'Throw the exception'); parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { $code = static fn (string $flag): string => "`cli(\$text, {$flag})`"; diff --git a/demo/Commands/ExamplesProfile.php b/demo/Commands/DemoProfile.php similarity index 91% rename from demo/Commands/ExamplesProfile.php rename to demo/Commands/DemoProfile.php index f9d7f4c..2e993d2 100644 --- a/demo/Commands/ExamplesProfile.php +++ b/demo/Commands/DemoProfile.php @@ -20,20 +20,17 @@ use function JBZoo\Cli\cli; -class ExamplesProfile extends CliCommand +class DemoProfile extends CliCommand { protected function configure(): void { $this - ->setName('examples:profile') + ->setName('profile') ->setDescription('Examples of memory and time profiling'); parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { cli('Start cycles'); diff --git a/demo/Commands/DemoProgressBar.php b/demo/Commands/DemoProgressBar.php new file mode 100644 index 0000000..95baa34 --- /dev/null +++ b/demo/Commands/DemoProgressBar.php @@ -0,0 +1,158 @@ +setName('progress-bar') + ->setDescription('Examples of progress bar') + ->addOption('case', 'c', InputOption::VALUE_REQUIRED, 'Case name.') + ->addOption('no-sleep', 't', InputOption::VALUE_NONE, 'Disable sleep timer.'); + + parent::configure(); + } + + protected function executeAction(): int + { + $listOfUsers = $this->prepareListOfDemoUsers(); + $caseName = $this->getOptString('case', '', [ + self::CASE_SIMPLE, + self::CASE_MESSAGES, + self::CASE_ARRAY, + self::CASE_BREAK, + self::CASE_EXCEPTION, + self::CASE_EXCEPTION_LIST, + self::CASE_MILLION, + ]); + + // Just 5 simple steps ///////////////////////////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_SIMPLE) { + $this->progressBar(5, function (): void { + $this->sleep(); + }); + } + + // Simple progress with custom message based on callback arguments ///////////////////////////////////////////// + if ($caseName === self::CASE_MESSAGES) { + $this->progressBar($listOfUsers, function ($value, $key, $step) { + $this->sleep(); + + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; + }, 'Custom messages based on callback arguments'); + } + + // Use the associated array as a data source /////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_ARRAY) { + dump($listOfUsers); + $this->progressBar($listOfUsers, function ($value, $key, $step) { + $this->sleep(); + + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; + }, 'Handling associated array as a data source'); + } + + // Exit the loop programmatically ////////////////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_BREAK) { + dump($listOfUsers); + $this->progressBar($listOfUsers, function ($value, $key, $step) { + $this->sleep(); + if ($step === 3) { + throw new ExceptionBreak("Something went wrong with \$value={$value}"); + } + + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; + }, 'Exit the loop programmatically'); + } + + // Exception handling ////////////////////////////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_EXCEPTION) { + $this->progressBar(5, function ($value) { + $this->sleep(); + if ($value === 1) { + throw new Exception("Something went really wrong on step #{$value}"); + } + + return "\$value={$value}"; + }, 'Exception handling', false); + } + + // Ignoring and collecting exceptions. Throw an error only at the end. ///////////////////////////////////////// + if ($caseName === self::CASE_EXCEPTION_LIST) { + $this->progressBar(10, function ($value): void { + $this->sleep(); + if ($value % 3 === 0) { + throw new Exception("Something went really wrong on step #{$value}"); + } + }, 'Ignoring and collecting exceptions. Throw them only at the end.', true); + } + + // 1 000 000 Items Benchmark /////////////////////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_MILLION) { + $this->progressBar( + 1_000_000, + static fn ($value, $key, $step) => 'Callback Args ' . + "\$value={$value}, \$key={$key}, \$step={$step}", + '1 000 000 items benchmark', + ); + } + + return self::SUCCESS; + } + + private function prepareListOfDemoUsers(): array + { + $faker = Factory::create(); + + $users = []; + + for ($i = 0; $i < 5; $i++) { + $firstName = $faker->firstName; + $lastName = $faker->lastName; + $email = Slug::filter($firstName) . '@site.com'; + + $users[$email] = $firstName . ' ' . $lastName; + } + + return $users; + } + + private function sleep(): void + { + if ($this->getOptBool('no-sleep')) { + return; + } + + \usleep(\random_int(500, 1200) * 1000); + } +} diff --git a/demo/Commands/Simple.php b/demo/Commands/DemoSimple.php similarity index 92% rename from demo/Commands/Simple.php rename to demo/Commands/DemoSimple.php index 25feb7a..a0f7072 100644 --- a/demo/Commands/Simple.php +++ b/demo/Commands/DemoSimple.php @@ -18,7 +18,7 @@ use JBZoo\Cli\CliCommand; -class Simple extends CliCommand +class DemoSimple extends CliCommand { protected function configure(): void { @@ -28,9 +28,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { // Your code here diff --git a/demo/Commands/ExamplesStyles.php b/demo/Commands/DemoStyles.php similarity index 69% rename from demo/Commands/ExamplesStyles.php rename to demo/Commands/DemoStyles.php index c046f04..39d126a 100644 --- a/demo/Commands/ExamplesStyles.php +++ b/demo/Commands/DemoStyles.php @@ -21,29 +21,28 @@ use function JBZoo\Cli\cli; -class ExamplesStyles extends CliCommand +class DemoStyles extends CliCommand { protected function configure(): void { $this - ->setName('examples:styles') + ->setName('styles') ->setDescription('Examples of new CLI colors and styles'); parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { // Render list of values cli('Render list of values'); - cli(CliRender::list([ - 'Like a title', - 'Key' => 'Value', - 'Key #2' => 123, - ], '-')); + cli( + CliRender::list([ + 'Like a title', + 'Key' => 'Value', + 'Key #2' => 123, + ], '-'), + ); /** * Literally you can use the tags: @@ -71,21 +70,25 @@ protected function executeAction(): int cli(CliRender::list($listOfExamples, '*')); cli('Shortcuts:'); - cli(CliRender::list([ - '\' => 'Blink', - '\' => 'Bold', - '\' => 'Underscore', - '\' => 'Reverse', - '\' => 'Background', - ], '*')); + cli( + CliRender::list([ + '\' => 'Blink', + '\' => 'Bold', + '\' => 'Underscore', + '\' => 'Reverse', + '\' => 'Background', + ], '*'), + ); cli('Aliases:'); - cli(CliRender::list([ - '\' => 'Alias for \', - '\' => 'Alias for \', - '\' => 'Alias for \', - '\' => 'Alias for \', - ], '*')); + cli( + CliRender::list([ + '\' => 'Alias for \', + '\' => 'Alias for \', + '\' => 'Alias for \', + '\' => 'Alias for \', + ], '*'), + ); // Default success exist code is "0". Max value is 255. return self::SUCCESS; diff --git a/demo/Commands/ExamplesProgressBar.php b/demo/Commands/ExamplesProgressBar.php deleted file mode 100644 index 984fd19..0000000 --- a/demo/Commands/ExamplesProgressBar.php +++ /dev/null @@ -1,90 +0,0 @@ -setName('examples:progress-bar') - ->setDescription('Examples of progress bar') - ->addOption('exception', null, InputOption::VALUE_NONE, 'Throw exception') - ->addOption('exception-list', null, InputOption::VALUE_NONE, 'Throw list of exceptions'); - - parent::configure(); - } - - /** - * {@inheritDoc} - */ - protected function executeAction(): int - { - // ////////////////////////////////////////////////////////////////////// Just 3 steps - ProgressBar::run(2, static function ($stepValue, $stepIndex, $currentStep) { - \sleep(1); - - return "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}"; - }, 'Number of steps'); - - // ////////////////////////////////////////////////////////////////////// Assoc array. Step-by-step - $list = [ - 'key_1' => 'value_1', - 'key_2' => 'value_2', - 'key_3' => 'value_3', - ]; - ProgressBar::run($list, static fn ($stepValue, $stepIndex, $currentStep) => "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}", 'Assoc array'); - - // ////////////////////////////////////////////////////////////////////// Exit from the cycle - ProgressBar::run(3, static function ($stepValue, $stepIndex, $currentStep) { - if ($stepValue === 1) { - return ProgressBar::BREAK; - } - - return "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}"; - }, 'Exit from the cycle'); - - // ////////////////////////////////////////////////////////////////////// Exception - if ($this->getOptBool('exception')) { - ProgressBar::run(3, static function ($stepValue) { - if ($stepValue === 1) { - throw new Exception("Exception #{$stepValue}"); - } - - return "\$stepValue={$stepValue}"; - }, 'Exception handling', false); - } - - // ////////////////////////////////////////////////////////////////////// List of Exceptions - if ($this->getOptBool('exception-list')) { - ProgressBar::run(10, static function ($stepValue) { - if ($stepValue % 3 === 0) { - throw new Exception("Exception #{$stepValue}"); - } - - return "\$stepValue={$stepValue}"; - }, 'Handling list of exceptions at once', true); - } - - // Default success exist code is "0". Max value is 255. - return self::SUCCESS; - } -} diff --git a/demo/composer.json b/demo/composer.json index 46b3bfc..187aa29 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -6,12 +6,8 @@ "keywords" : ["cli", "application", "example"], "require" : { - "php" : ">=7.2", - "jbzoo/cli" : "1.x-dev" - }, - - "require-dev" : { - "roave/security-advisories" : "dev-latest" + "php" : "^8.1", + "jbzoo/cli" : "^7.1.0" }, "autoload" : { diff --git a/demo/movies/demo-magic.sh b/demo/movies/demo-magic.sh index d7929af..1eafd1c 100755 --- a/demo/movies/demo-magic.sh +++ b/demo/movies/demo-magic.sh @@ -24,13 +24,13 @@ ############################################################################### # the speed to "type" the text -TYPE_SPEED=25 +TYPE_SPEED=20 # no wait after "p" or "pe" NO_WAIT=false # if > 0, will pause for this amount of seconds before automatically proceeding with any p or pe -PROMPT_TIMEOUT=3 +PROMPT_TIMEOUT=7 # don't show command number unless user specifies it SHOW_CMD_NUMS=false @@ -54,7 +54,7 @@ C_NUM=0 # prompt and command color which can be overriden DEMO_PROMPT="\n$GREEN>$COLOR_RESET " DEMO_CMD_COLOR=$WHITE -DEMO_COMMENT_COLOR=$GREY +DEMO_COMMENT_COLOR=$WHITE ## # prints the script usage @@ -114,16 +114,15 @@ function p() { fi if [[ -z $TYPE_SPEED ]]; then - echo -en "$cmd" + echo "$cmd" else - echo -en "$cmd" | pv -qL $[$TYPE_SPEED+(-2 + RANDOM%5)]; + echo "$cmd" | pv -qL $[$TYPE_SPEED+(-2 + RANDOM%5)]; fi # wait for the user to press a key before moving on if [ $NO_WAIT = false ]; then wait fi - echo "" } ## diff --git a/demo/movies/ExamplesOutput.sh b/demo/movies/output.sh similarity index 72% rename from demo/movies/ExamplesOutput.sh rename to demo/movies/output.sh index 516feb0..cf34d20 100755 --- a/demo/movies/ExamplesOutput.sh +++ b/demo/movies/output.sh @@ -16,7 +16,6 @@ cd .. -# Hide the evidence clear PROMPT_TIMEOUT=7 @@ -24,80 +23,99 @@ PROMPT_TIMEOUT=7 pei "# This is demo of different output levels of JBZoo/Cli framework." pei "# We have a lot of output levels, which can be used for different purposes." pei "# For the demo I've prepared a simple script, which will print some messages." -pei "# See it here './demo/Commands/ExamplesOutput.php'" +pei "# See it here './demo/Commands/DemoOutput.php'" pei "# Let's get started!" wait +pei "" +pei "" pei "# At first, let me show you the output of the command by default." -pei "./my-app examples:output" +pei "./my-app output" wait pei "clear" pei "# There are several lines that written in Standard Error output (stderr)." -pei "./my-app examples:output > /dev/null" +pei "./my-app output > /dev/null" wait pei "clear" pei "# There is a special level to show a messge forever." -pei "./my-app examples:output --quiet" +pei "./my-app output --quiet" wait pei "clear" pei "# And pay attentin on old school style output." -pei "./my-app examples:output --stdout-only | grep 'Legacy Output'" +pei "./my-app output --stdout-only | grep 'Legacy Output'" wait pei "clear" pei "# Let's increase the output level. Just add the flag '-v'. Look at 'Info:'" -pei "./my-app examples:output -v" +pei "./my-app output -v" wait pei "clear" pei "# Next, let's look at more detailed logs (-vv). Look at 'Warning:'" -pei "./my-app examples:output -vv" +pei "./my-app output -vv" wait pei "clear" pei "# And messages that are only useful to developers during application debugging (-vvv). Look at 'Debug:'" -pei "./my-app examples:output -vvv" +pei "./my-app output -vvv" wait pei "clear" pei "# There is an easy way to find memory leaks and performance issues. Just add '--profile' flag." -pei "./my-app examples:output --profile" +pei "./my-app output --profile" wait pei "clear" pei "# Also, we can use the output as logs. It's pretty useful for cron jobs." -pei "./my-app examples:output --profile --timestamp" +pei "./my-app output --profile --timestamp" +wait +pei "clear" + + +pei "# You can quickly switch to crontab mode" +pei "./my-app output --output-mode=cron" +wait +pei "clear" + + +pei "" +pei "# It's ready for ELK Stack (Logstash)." +pei "./my-app output --output-mode=logstash | jq" wait pei "clear" pei "# Let's simulate a fatal error." -pei "./my-app examples:output --throw-custom-exception" +pei "./my-app output --throw-custom-exception" wait pei "clear" pei "# Sometimes we have to ignore exception not to break the pipeline." -pei "./my-app examples:output --throw-custom-exception --mute-errors -vvv" +pei "./my-app output --throw-custom-exception --mute-errors -vvv" +pei "" +pei "" pei "# Look at the last lines." wait pei "clear" pei "# In rare cases we can use the flag '--non-zero-on-error' to return ExitCode=1 if any stderr happend." -pei "./my-app examples:output --non-zero-on-error -vvv" +pei "./my-app output --non-zero-on-error -vvv" +pei "" +pei "" pei "# Look at the last lines." wait pei "clear" diff --git a/demo/movies/progress-bar.sh b/demo/movies/progress-bar.sh new file mode 100755 index 0000000..261f2b2 --- /dev/null +++ b/demo/movies/progress-bar.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh + +# +# JBZoo Toolbox - Cli. +# +# This file is part of the JBZoo Toolbox project. +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +# +# @license MIT +# @copyright Copyright (C) JBZoo.com, All rights reserved. +# @see https://github.com/JBZoo/Cli +# + +. demo-magic.sh + +cd .. +clear + +pei "# In this demo, you will see the basic features of Progress Bar for CLI app." +pei "# ProgressBar helps you perform looping actions and output extra info to profile your app." +pei "# Now we will take a look at its main features." +pei "# See it here './demo/Commands/DemoOutput.php'" +pei "# Let's get started!" +wait + + +pei "" +pei "" +pei "# At first, let me show you the output of the progress by default." +pei "./my-app progress-bar --case=simple" +wait +pei "clear" + + +pei "# Different levels of verbosity give different levels of detail." + +pei "" +pei "" +pei "# Verbose level (-v)" +pei "./my-app progress-bar --case=messages -v" +wait + +pei "" +pei "# Very Verbose level (-vv)" +pei "./my-app progress-bar --case=messages -vv" +wait + +pei "" +pei "# Debug level, max (-vvv)" +pei "./my-app progress-bar --case=messages -vvv" +wait +pei "clear" + + +pei "# You can use any iterated object as a data source for the widget." +pei "# Let's look at an associative array as an example." +pei "./my-app progress-bar --case=array" +pei "# As you can see, you can customize the message. This is useful for logs." +wait +pei "clear" + + +pei "# You can easily disable progress bar" +pei "./my-app progress-bar --case=messages --no-progress" +wait + +pei "" +pei "# Or quickly switch to crontab mode" +pei "./my-app progress-bar --case=messages --output-mode=cron" +wait +pei "clear" + + +pei "" +pei "# It's ready for ELK Stack (Logstash)." +pei "./my-app progress-bar --case=messages --output-mode=logstash | jq" +wait +pei "clear" + + +pei "# It is easy to interrupt the execution." +pei "./my-app progress-bar --case=break" +wait +pei "clear" + + +pei "# If an unexpected error occurs, it will stop execution and display detailed information on the screen." +pei "./my-app progress-bar --case=exception -vv" +wait +pei "clear" + + +pei "# You can catch all exceptions without interrupting execution." +pei "# And output only one single generic message at the end." +pei "./my-app progress-bar --case=exception-list -vv" +wait +pei "clear" + + +pei "##############################" +pei "# That's all for this demo. #" +pei "# Have a nice day =) #" +pei "# Thank you! #" +pei "##############################" diff --git a/demo/my-app b/demo/my-app index bb6f898..0d12e48 100755 --- a/demo/my-app +++ b/demo/my-app @@ -20,40 +20,37 @@ namespace DemoApp; use JBZoo\Cli\CliApplication; // Init composer autoloader -if (file_exists(__DIR__ . '/vendor/autoload.php')) { +if (\file_exists(__DIR__ . '/vendor/autoload.php')) { require_once __DIR__ . '/vendor/autoload.php'; } else { - require_once dirname(__DIR__) . '/vendor/autoload.php'; + require_once \dirname(__DIR__) . '/vendor/autoload.php'; } - // Set your application name and version. $application = new CliApplication('My Console Application', 'v1.0.0'); - // Looks at the online generator of ASCII logos // https://patorjk.com/software/taag/#p=testall&f=Epic&t=My%20Console%20App -$application->setLogo(implode(PHP_EOL, [ - " __ __ _____ _ ", - " | \/ | / ____| | | /\ ", - " | \ / |_ _ | | ___ _ __ ___ ___ | | ___ / \ _ __ _ __ ", - " | |\/| | | | | | | / _ \| '_ \/ __|/ _ \| |/ _ \ / /\ \ | '_ \| '_ \ ", - " | | | | |_| | | |___| (_) | | | \__ \ (_) | | __/ / ____ \| |_) | |_) |", - " |_| |_|\__, | \_____\___/|_| |_|___/\___/|_|\___| /_/ \_\ .__/| .__/ ", - " __/ | | | | | ", - " |___/ |_| |_| ", -])); - +$application->setLogo( + <<<'EOF' + __ __ _____ _ + | \/ | / ____| | | /\ + | \ / |_ _ | | ___ _ __ ___ ___ | | ___ / \ _ __ _ __ + | |\/| | | | | | | / _ \| '_ \/ __|/ _ \| |/ _ \ / /\ \ | '_ \| '_ \ + | | | | |_| | | |___| (_) | | | \__ \ (_) | | __/ / ____ \| |_) | |_) | + |_| |_|\__, | \_____\___/|_| |_|___/\___/|_|\___| /_/ \_\ .__/| .__/ + __/ | | | | | + |___/ |_| |_| + EOF, +); // Scan directory to find commands. // * It doesn't work recursively! // * They must be inherited from the class \JBZoo\Cli\CliCommand $application->registerCommandsByPath(__DIR__ . '/Commands', __NAMESPACE__); - // Action name by default (if there is no arguments) $application->setDefaultCommand('list'); - // Run application $application->run(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7989ab0..e3a3b9a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,13 +17,9 @@ - + - + diff --git a/src/Cli.php b/src/Cli.php deleted file mode 100644 index 7f649f9..0000000 --- a/src/Cli.php +++ /dev/null @@ -1,347 +0,0 @@ -prevMemory = \memory_get_usage(false); - $this->startTimer = \microtime(true); - $this->prevTime = $this->startTimer; - - $this->input = $input; - $this->output = self::addOutputStyles($output); - - $errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - $errOutput = self::addOutputStyles($errOutput); - - if ($this->isCron()) { - $this->output->setDecorated(false); - if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERY_VERBOSE) { - $this->output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); - } - } - - if ($this->isStdoutOnly()) { - $this->errOutput = $this->output; - if ($this->output instanceof ConsoleOutput) { - $this->output->setErrorOutput($this->output); - } - } else { - $this->errOutput = $errOutput; - } - - self::$instance = $this; - } - - public function getStartTime(): float - { - return $this->startTimer; - } - - public function getInput(): InputInterface - { - return $this->input; - } - - public function getOutput(): OutputInterface - { - return $this->output; - } - - public function getErrOutput(): OutputInterface - { - return $this->errOutput; - } - - public function getProfileInfo(): array - { - $currentTime = \microtime(true); - $currentMemory = \memory_get_usage(false); - - $currDiff = $currentMemory - $this->prevMemory; - $result = [ - \number_format($currentTime - $this->prevTime, 3), - ($currDiff < 0 ? '-' : '+') . FS::format(\abs($currDiff)), - ]; - - $this->prevTime = $currentTime; - $this->prevMemory = $currentMemory; - - return $result; - } - - /** - * Alias to write new line in std output. - * - * @param null|array|bool|float|int|string $messages - * - * @SuppressWarnings(PHPMD.CamelCaseMethodName) - * @SuppressWarnings(PHPMD.NPathComplexity) - */ - public function _($messages = '', string $verboseLevel = OutLvl::DEFAULT): void - { - $verboseLevel = \strtolower(\trim($verboseLevel)); - - if (\is_array($messages)) { - if (\count($messages) === 0) { - return; - } - - foreach ($messages as $message) { - $this->_($message, $verboseLevel); - } - - return; - } - - if ($messages === null) { - $messages = 'null'; - } elseif (\is_bool($messages)) { - $messages = $messages ? 'true' : 'false'; - } - - $messages = (string)$messages; - - if (\str_contains($messages, "\n")) { - $this->_(\explode("\n", $messages), $verboseLevel); - - return; - } - - $profilePrefix = ''; - - if ($this->isDisplayTimestamp()) { - $timestamp = (new \DateTimeImmutable())->format(self::TIMESTAMP_FORMAT); - $profilePrefix .= "[{$timestamp}] "; - } - - if ($this->isDisplayProfiling()) { - [$totalTime, $curMemory] = $this->getProfileInfo(); - $curMemory = \str_pad($curMemory, 10, ' ', \STR_PAD_LEFT); - $profilePrefix .= "[+{$totalTime}s/{$curMemory}] "; - } - - $vNormal = OutputInterface::VERBOSITY_NORMAL; - - if ($verboseLevel === OutLvl::DEFAULT) { - $this->output->writeln($profilePrefix . $messages, $vNormal); - } elseif ($verboseLevel === OutLvl::V) { - $this->output->writeln($profilePrefix . $messages, OutputInterface::VERBOSITY_VERBOSE); - } elseif ($verboseLevel === OutLvl::VV) { - $this->output->writeln($profilePrefix . $messages, OutputInterface::VERBOSITY_VERY_VERBOSE); - } elseif ($verboseLevel === OutLvl::VVV) { - $this->output->writeln($profilePrefix . $messages, OutputInterface::VERBOSITY_DEBUG); - } elseif ($verboseLevel === OutLvl::Q) { - $this->output->writeln($profilePrefix . $messages, OutputInterface::VERBOSITY_QUIET); // Show ALWAYS! - } elseif ($verboseLevel === OutLvl::LEGACY) { - $this->_('Legacy Output: ' . $messages); - } elseif ($verboseLevel === OutLvl::DEBUG) { - $this->_('Debug: ' . $messages, OutLvl::VVV); - } elseif ($verboseLevel === OutLvl::WARNING) { - $this->_('Warning: ' . $messages, OutLvl::VV); - } elseif ($verboseLevel === OutLvl::INFO) { - $this->_('Info: ' . $messages, OutLvl::V); - } elseif ($verboseLevel === OutLvl::E) { - $this->outputHasErrors = true; - $this->getErrOutput()->writeln($profilePrefix . $messages, $vNormal); - } elseif ($verboseLevel === OutLvl::ERROR) { - $this->outputHasErrors = true; - $this->getErrOutput()->writeln($profilePrefix . 'Error: ' . $messages, $vNormal); - } elseif ($verboseLevel === OutLvl::EXCEPTION) { - $this->outputHasErrors = true; - $this->getErrOutput()->writeln($profilePrefix . 'Muted Exception: ' . $messages, $vNormal); - } else { - throw new Exception("Undefined verbose level: \"{$verboseLevel}\""); - } - } - - public function isOutputHasErrors(): bool - { - return $this->outputHasErrors; - } - - public function isCron(): bool - { - return bool($this->input->getOption('cron')); - } - - public function isStdoutOnly(): bool - { - return bool($this->input->getOption('stdout-only')) || $this->isCron(); - } - - public function isDisplayProfiling(): bool - { - return bool($this->input->getOption('profile')) || $this->isCron(); - } - - public function isDisplayTimestamp(): bool - { - return bool($this->input->getOption('timestamp')) || $this->isCron(); - } - - public function isInfoLevel(): bool - { - return $this->getOutput()->isVerbose() || $this->isCron(); - } - - public function isWarningLevel(): bool - { - return $this->getOutput()->isVeryVerbose() || $this->isCron(); - } - - public function isDebugLevel(): bool - { - return $this->getOutput()->isDebug(); - } - - public function isProgressBarDisabled(): bool - { - return bool($this->getInput()->getOption('no-progress')) || $this->isCron(); - } - - /** - * @see https://github.com/phpstan/phpstan-src/blob/f8be122188/src/Process/CpuCoreCounter.php - */ - public function getNumberOfCpuCores(): int - { - if ($this->numberOfCpuCores !== null) { - return $this->numberOfCpuCores; - } - - if (!\function_exists('proc_open')) { - return $this->numberOfCpuCores = 1; - } - - // from brianium/paratest - // Linux (and potentially Windows with linux sub systems) - if (\is_file('/proc/cpuinfo')) { - $cpuinfo = \file_get_contents('/proc/cpuinfo'); - if ($cpuinfo !== false) { - \preg_match_all('/^processor/m', $cpuinfo, $matches); - - return $this->numberOfCpuCores = \count($matches[0]); - } - } - - // Windows - if (\DIRECTORY_SEPARATOR === '\\') { - $process = \popen('wmic cpu get NumberOfLogicalProcessors', 'rb'); - if (\is_resource($process)) { - /** @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown */ - \fgets($process); - $cores = int(\fgets($process)); - \pclose($process); - - return $this->numberOfCpuCores = $cores; - } - } - - // *nix (Linux, BSD and Mac) - $process = \popen('sysctl -n hw.ncpu', 'rb'); - if (\is_resource($process)) { - $cores = int(\fgets($process)); - \pclose($process); - - return $this->numberOfCpuCores = $cores; - } - - return $this->numberOfCpuCores = 2; - } - - public static function getInstance(): self - { - return self::$instance; - } - - public static function getRootPath(): string - { - $rootPath = \defined('JBZOO_PATH_ROOT') ? (string)JBZOO_PATH_ROOT : null; - if (isStrEmpty($rootPath)) { - return Env::string('JBZOO_PATH_ROOT'); - } - - return (string)$rootPath; - } - - public static function getBinPath(): string - { - $binPath = \defined('JBZOO_PATH_BIN') ? (string)JBZOO_PATH_BIN : null; - if (isStrEmpty($binPath)) { - return Env::string('JBZOO_PATH_BIN'); - } - - return (string)$binPath; - } - - public static function addOutputStyles(OutputInterface $output): OutputInterface - { - $formatter = $output->getFormatter(); - $defaultColor = 'default'; - - $colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', $defaultColor]; - - foreach ($colors as $color) { - $formatter->setStyle($color, new OutputFormatterStyle($color)); - $formatter->setStyle("{$color}-b", new OutputFormatterStyle($color, null, ['bold'])); - $formatter->setStyle("{$color}-u", new OutputFormatterStyle($color, null, ['underscore'])); - $formatter->setStyle("{$color}-r", new OutputFormatterStyle($color, null, ['reverse'])); - $formatter->setStyle("{$color}-bg", new OutputFormatterStyle(null, $color)); - $formatter->setStyle("{$color}-bl", new OutputFormatterStyle($color, null, ['blink'])); - } - - $formatter->setStyle('bl', new OutputFormatterStyle($defaultColor, null, ['blink'])); - $formatter->setStyle('b', new OutputFormatterStyle($defaultColor, null, ['bold'])); - $formatter->setStyle('u', new OutputFormatterStyle($defaultColor, null, ['underscore'])); - $formatter->setStyle('r', new OutputFormatterStyle(null, null, ['reverse'])); - $formatter->setStyle('bg', new OutputFormatterStyle('black', 'white')); - - // Aliases - $formatter->setStyle('i', new OutputFormatterStyle('green')); // Alias for - $formatter->setStyle('c', new OutputFormatterStyle('yellow')); // Alias for - $formatter->setStyle('q', new OutputFormatterStyle('black', 'cyan')); // Alias for - $formatter->setStyle('e', new OutputFormatterStyle('white', 'red')); // Alias for - - return $output; - } -} diff --git a/src/CliApplication.php b/src/CliApplication.php index 04a52c1..7e01ddf 100644 --- a/src/CliApplication.php +++ b/src/CliApplication.php @@ -16,16 +16,20 @@ namespace JBZoo\Cli; +use JBZoo\Cli\OutputMods\AbstractOutputMode; +use JBZoo\Cli\OutputMods\Text; use JBZoo\Event\EventManager; use JBZoo\Utils\FS; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Output\OutputInterface; use function JBZoo\Utils\isStrEmpty; class CliApplication extends Application { - private ?EventManager $eventManager = null; - private ?string $logo = null; + private ?EventManager $eventManager = null; + private ?string $logo = null; + private ?AbstractOutputMode $outputMode = null; /** * Register commands by directory path. @@ -90,6 +94,16 @@ public function setLogo(?string $logo = null): self return $this; } + /** + * @SuppressWarnings(PHPMD.ShortVariable) + */ + public function renderThrowable(\Throwable $e, OutputInterface $output): void + { + if ($this->outputMode === null || $this->outputMode instanceof Text) { + parent::renderThrowable($e, $output); + } + } + /** * Returns the long version of the application. */ @@ -101,4 +115,11 @@ public function getLongVersion(): string return parent::getLongVersion(); } + + public function setOutputMode(AbstractOutputMode $outputMode): self + { + $this->outputMode = $outputMode; + + return $this; + } } diff --git a/src/CliCommand.php b/src/CliCommand.php index 99afc50..e42fc0d 100644 --- a/src/CliCommand.php +++ b/src/CliCommand.php @@ -16,8 +16,12 @@ namespace JBZoo\Cli; +use JBZoo\Cli\OutputMods\AbstractOutputMode; +use JBZoo\Cli\OutputMods\Cron; +use JBZoo\Cli\OutputMods\Logstash; +use JBZoo\Cli\OutputMods\Text; +use JBZoo\Cli\ProgressBars\AbstractProgressBar; use JBZoo\Utils\Arr; -use JBZoo\Utils\FS; use JBZoo\Utils\Str; use JBZoo\Utils\Vars; use Symfony\Component\Console\Command\Command; @@ -35,40 +39,106 @@ abstract class CliCommand extends Command { - /** @psalm-suppress PropertyNotSetInConstructor */ - protected Cli $helper; + protected AbstractOutputMode $outputMode; abstract protected function executeAction(): int; + public function progressBar( + iterable|int $listOrMax, + \Closure $callback, + string $title = '', + bool $throwBatchException = true, + ?AbstractOutputMode $outputMode = null, + ): AbstractProgressBar { + static $nestedLevel = 0; + + $outputMode ??= $this->outputMode; + + $progressBar = $outputMode->createProgressBar() + ->setTitle($title) + ->setCallback($callback) + ->setThrowBatchException($throwBatchException); + + if (\is_iterable($listOrMax)) { + $progressBar->setList($listOrMax); + } else { + $progressBar->setMax($listOrMax); + } + + $nestedLevel++; + $progressBar->setNextedLevel($nestedLevel); + + $progressBar->execute(); + + $nestedLevel--; + $progressBar->setNextedLevel($nestedLevel); + + return $progressBar; + } + /** * {@inheritDoc} */ protected function configure(): void { + $definedShortcuts = !\defined('\JBZOO_CLI_NO_PREDEFINED_SHORTCUTS'); + $this - ->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar animation for logs') + ->addOption( + 'no-progress', + $definedShortcuts ? 'P' : null, + InputOption::VALUE_NONE, + 'Disable progress bar animation for logs. ' . + 'It will be used only for ' . Text::getName() . ' output format.', + ) ->addOption( 'mute-errors', - null, + $definedShortcuts ? 'M' : null, InputOption::VALUE_NONE, "Mute any sort of errors. So exit code will be always \"0\" (if it's possible).\n" . "It has major priority then --non-zero-on-error. It's on your own risk!", ) ->addOption( 'stdout-only', - null, + $definedShortcuts ? '1' : null, InputOption::VALUE_NONE, "For any errors messages application will use StdOut instead of StdErr. It's on your own risk!", ) - ->addOption('non-zero-on-error', null, InputOption::VALUE_NONE, 'None-zero exit code on any StdErr message') - ->addOption('timestamp', null, InputOption::VALUE_NONE, 'Show timestamp at the beginning of each message') - ->addOption('profile', null, InputOption::VALUE_NONE, 'Display timing and memory usage information') ->addOption( - 'cron', + 'non-zero-on-error', + $definedShortcuts ? 'Z' : null, + InputOption::VALUE_NONE, + 'None-zero exit code on any StdErr message.', + ) + ->addOption( + 'timestamp', + $definedShortcuts ? 'T' : null, + InputOption::VALUE_NONE, + 'Show timestamp at the beginning of each message.' . + 'It will be used only for ' . Text::getName() . ' output format.', + ) + ->addOption( + 'profile', + $definedShortcuts ? 'X' : '', + InputOption::VALUE_NONE, + 'Display timing and memory usage information.', + ) + ->addOption( + 'output-mode', + $definedShortcuts ? 'O' : null, + InputOption::VALUE_REQUIRED, + "Output format. Available options:\n" . CliHelper::renderListForHelpDescription([ + Text::getName() => Text::getDescription(), + Cron::getName() => Cron::getDescription(), + Logstash::getName() => Logstash::getDescription(), + ]), + Text::getName(), + ) + ->addOption( + Cron::getName(), null, InputOption::VALUE_NONE, - "Shortcut for crontab. It's basically focused on logs output. " - . 'It\'s combination of --timestamp --profile --stdout-only --no-progress -vv', + 'Alias for --output-mode=' . Cron::getName() . '. Deprecated!', ); parent::configure(); @@ -79,14 +149,14 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $this->helper = new Cli($input, $output); - - $this->_('Working Directory is ' . \getcwd() . '', OutLvl::DEBUG); + $this->outputMode = $this->createOutputMode($input, $output, self::getOutputFormat($input)); + $this->getCliApplication()->setOutputMode($this->outputMode); - $exitCode = 0; + $exitCode = Codes::OK; try { - $this->trigger('exec.before', [$this, $this->helper]); + $this->outputMode->onExecBefore(); + $this->trigger('exec.before', [$this, $this->outputMode]); \ob_start(); $exitCode = $this->executeAction(); $echoContent = \ob_get_clean(); @@ -99,31 +169,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->showLegacyOutput($echoContent); } - $this->trigger('exception', [$this, $this->helper, $exception]); + $this->outputMode->onExecException($exception); + $this->trigger('exception', [$this, $this->outputMode, $exception]); - if ($this->getOptBool('mute-errors')) { - $this->_($exception->getMessage(), OutLvl::EXCEPTION); - } else { - $this->showProfiler(); + $this->outputMode->onExecAfter($exitCode); + + if (!$this->getOptBool('mute-errors')) { throw $exception; } } $exitCode = Vars::range($exitCode, 0, 255); - if ($this->helper->isOutputHasErrors() && $this->getOptBool('non-zero-on-error')) { - $exitCode = 1; + if ($this->outputMode->isOutputHasErrors() && $this->getOptBool('non-zero-on-error')) { + $exitCode = Codes::GENERAL_ERROR; } - $this->trigger('exec.after', [$this, $this->helper, &$exitCode]); - $this->showProfiler(); + $this->outputMode->onExecAfter($exitCode); + $this->trigger('exec.after', [$this, $this->outputMode, &$exitCode]); if ($this->getOptBool('mute-errors')) { $exitCode = 0; } - $this->_("Exit Code is \"{$exitCode}\"", OutLvl::DEBUG); - return $exitCode; } @@ -132,7 +200,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ protected function getOpt(string $optionName, bool $canBeArray = true): mixed { - $value = $this->helper->getInput()->getOption($optionName); + $value = $this->outputMode->getInput()->getOption($optionName); if ($canBeArray && \is_array($value)) { return Arr::last($value); @@ -151,26 +219,59 @@ protected function getOptBool(string $optionName): bool return bool($value); } - protected function getOptInt(string $optionName): int + /** + * @param int[] $onlyExpectedOptions + */ + protected function getOptInt(string $optionName, array $onlyExpectedOptions = []): int { - $value = $this->getOpt($optionName) ?? 0; + $value = $this->getOpt($optionName) ?? 0; + $result = int($value); + + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected int-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } return int($value); } - protected function getOptFloat(string $optionName): float + /** + * @param float[] $onlyExpectedOptions + */ + protected function getOptFloat(string $optionName, array $onlyExpectedOptions = []): float { - $value = $this->getOpt($optionName) ?? 0.0; + $value = $this->getOpt($optionName) ?? 0.0; + $result = float($value); - return float($value); + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected float-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } + + return $result; } - protected function getOptString(string $optionName): string + /** + * @param string[] $onlyExpectedOptions + */ + protected function getOptString(string $optionName, string $default = '', array $onlyExpectedOptions = []): string { $value = \trim((string)$this->getOpt($optionName)); $length = \strlen($value); + $result = $length > 0 ? $value : $default; - return $length > 0 ? $value : ''; + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } + + return $result; } protected function getOptArray(string $optionName): array @@ -180,48 +281,57 @@ protected function getOptArray(string $optionName): array return (array)$list; } + /** + * @param string[] $onlyExpectedOptions + */ protected function getOptDatetime( string $optionName, string $defaultDatetime = '1970-01-01 00:00:00', + array $onlyExpectedOptions = [], ): \DateTimeImmutable { - $value = $this->getOptString($optionName); - $dateAsString = $value === '' ? $defaultDatetime : $value; + $value = $this->getOptString($optionName); + $result = $value === '' ? $defaultDatetime : $value; - return new \DateTimeImmutable($dateAsString); + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option {$optionName}={$result}. " . + 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } + + return new \DateTimeImmutable($result); } /** * Alias to write new line in std output. * @SuppressWarnings(PHPMD.CamelCaseMethodName) */ - protected function _(mixed $messages = '', string $verboseLevel = ''): void - { - $this->helper->_($messages, $verboseLevel); + protected function _( + iterable|string|int|float|bool|null $messages = '', + string $verboseLevel = '', + array $context = [], + ): void { + $this->outputMode->_($messages, $verboseLevel, $context); } protected function isInfoLevel(): bool { - return $this->helper->isInfoLevel(); + return $this->outputMode->isInfoLevel(); } protected function isWarningLevel(): bool { - return $this->helper->isWarningLevel(); + return $this->outputMode->isWarningLevel(); } protected function isDebugLevel(): bool { - return $this->helper->isDebugLevel(); + return $this->outputMode->isDebugLevel(); } protected function isProfile(): bool { - return $this->helper->isDisplayProfiling(); - } - - protected function isCron(): bool - { - return $this->helper->isCron(); + return $this->outputMode->isDisplayProfiling(); } protected function trigger(string $eventName, array $arguments = [], ?callable $continueCallback = null): int @@ -258,8 +368,8 @@ protected function ask(string $question, string $default = '', bool $isHidden = } return (string)$this->getQuestionHelper()->ask( - $this->helper->getInput(), - $this->helper->getOutput(), + $this->outputMode->getInput(), + $this->outputMode->getOutput(), $questionObj, ); } @@ -285,8 +395,8 @@ protected function confirmation(string $question = 'Are you sure?', bool $defaul ); return (bool)$this->getQuestionHelper()->ask( - $this->helper->getInput(), - $this->helper->getOutput(), + $this->outputMode->getInput(), + $this->outputMode->getOutput(), $questionObj, ); } @@ -311,16 +421,15 @@ protected function askOption(string $question, array $options, int|float|string| $questionObj->setErrorMessage('The option "%s" is undefined. See the avaialable options'); return (string)$this->getQuestionHelper()->ask( - $this->helper->getInput(), - $this->helper->getOutput(), + $this->outputMode->getInput(), + $this->outputMode->getOutput(), $questionObj, ); } protected static function getStdIn(): ?string { - // It can be read only once, so we save result as internal varaible - static $result; + static $result; // It can be read only once, so we save result as internal varaible if ($result === null) { $result = ''; @@ -333,29 +442,10 @@ protected static function getStdIn(): ?string return $result; } - private function showProfiler(): void - { - if (!$this->isProfile()) { - return; - } - - $totalTime = \number_format(\microtime(true) - $this->helper->getStartTime(), 3); - $curMemory = FS::format(\memory_get_usage(false)); - $maxMemory = FS::format(\memory_get_peak_usage(true)); - - $this->_( - \implode('; ', [ - "Memory Usage/Peak: {$curMemory}/{$maxMemory}", - "Execution Time: {$totalTime} sec", - ]), - ); - } - private function showLegacyOutput(string $echoContent): void { $lines = \explode("\n", $echoContent); $lines = \array_map(static fn ($line) => \rtrim($line), $lines); - $lines = \array_filter($lines, static fn ($line): bool => $line !== ''); if (\count($lines) > 1) { @@ -374,4 +464,49 @@ private function getQuestionHelper(): QuestionHelper throw new Exception('Symfony QuestionHelper not found'); } + + private function createOutputMode( + InputInterface $input, + OutputInterface $output, + string $outputFormat, + ): AbstractOutputMode { + $application = $this->getCliApplication(); + + if ($outputFormat === Text::getName()) { + return new Text($input, $output, $application); + } + + if ($outputFormat === Cron::getName()) { + return new Cron($input, $output, $application); + } + + if ($outputFormat === Logstash::getName()) { + return new Logstash($input, $output, $application); + } + + throw new Exception("Unknown output format: {$outputFormat}"); + } + + private function getCliApplication(): CliApplication + { + $application = $this->getApplication(); + if ($application === null) { + throw new Exception('Application not defined. Please, use "setApplication()" method.'); + } + + if ($application instanceof CliApplication) { + return $application; + } + + throw new Exception('Application must be instance of "\JBZoo\Cli\CliApplication"'); + } + + private static function getOutputFormat(InputInterface $input): string + { + if (bool($input->getOption('cron'))) { // TODO: Must be deprecated in the future + return Cron::getName(); + } + + return $input->getOption('output-mode') ?? Text::getName(); + } } diff --git a/src/CliCommandMultiProc.php b/src/CliCommandMultiProc.php index 22c4336..b1f030e 100644 --- a/src/CliCommandMultiProc.php +++ b/src/CliCommandMultiProc.php @@ -117,7 +117,7 @@ protected function executeAction(): int protected function executeMultiProcessAction(): int { $procNum = $this->getNumberOfProcesses(); - $cpuCores = $this->helper->getNumberOfCpuCores(); + $cpuCores = CliHelper::getNumberOfCpuCores(); $this->_("Max number of sub-processes: {$procNum}", OutLvl::DEBUG); if ($procNum > $cpuCores) { $this->_( @@ -131,8 +131,9 @@ protected function executeMultiProcessAction(): int $procListIds = $this->getListOfChildIds(); - if (!$this->helper->isProgressBarDisabled()) { - $this->progressBar = new ProgressBarProcessManager($this->helper->getOutput(), \count($procListIds)); + if (!$this->outputMode->isProgressBarDisabled()) { + $this->progressBar = new ProgressBarProcessManager($this->outputMode); + $this->progressBar->setMax(\count($procListIds)); $this->progressBar->start(); } @@ -209,7 +210,7 @@ private function createSubProcess(string $procId): Process { // Prepare option list from the parent process $options = \array_filter( - $this->helper->getInput()->getOptions(), + $this->outputMode->getInput()->getOptions(), static fn ($optionValue): bool => $optionValue !== false && $optionValue !== '', ); @@ -225,7 +226,7 @@ private function createSubProcess(string $procId): Process $options['pm-proc-id'] = $procId; // Prepare $argument list from the parent process - $arguments = $this->helper->getInput()->getArguments(); + $arguments = $this->outputMode->getInput()->getArguments(); $argumentsList = []; foreach ($arguments as $argKey => $argValue) { @@ -247,14 +248,14 @@ private function createSubProcess(string $procId): Process ' ', \array_filter([ Sys::getBinary(), - Cli::getBinPath(), + CliHelper::getBinPath(), $this->getName(), \implode(' ', $argumentsList), ]), ), $options, ), - Cli::getRootPath(), + CliHelper::getRootPath(), null, null, $this->getMaxTimeout(), @@ -335,7 +336,7 @@ private function getPmStartDelay(): int private function getNumberOfProcesses(): int { $pmMax = \strtolower($this->getOptString('pm-max')); - $cpuCores = $this->helper->getNumberOfCpuCores(); + $cpuCores = CliHelper::getNumberOfCpuCores(); if ($pmMax === 'auto') { return $cpuCores; diff --git a/src/CliHelper.php b/src/CliHelper.php new file mode 100644 index 0000000..7f3ce86 --- /dev/null +++ b/src/CliHelper.php @@ -0,0 +1,130 @@ + $value) { + $result .= "{$key} - {$value}\n"; + } + + return $result; + } + + public static function createOrGetTraceId(): string + { + static $traceId = null; + + if ($traceId === null) { + $traceId = Str::uuid(); + } + + return $traceId; + } + + public static function renderExpectedValues(array $values): string + { + $result = ''; + + foreach ($values as $value) { + $result .= "\"{$value}\", "; + } + + return \rtrim($result); + } +} diff --git a/src/OutLvl.php b/src/OutLvl.php index 3eb5d42..6c97ea1 100644 --- a/src/OutLvl.php +++ b/src/OutLvl.php @@ -16,6 +16,10 @@ namespace JBZoo\Cli; +use Monolog\Level; +use Psr\Log\LogLevel; +use Symfony\Component\Console\Output\OutputInterface; + class OutLvl { public const Q = 'q'; @@ -31,4 +35,45 @@ class OutLvl public const ERROR = 'error'; public const EXCEPTION = 'exception'; public const LEGACY = 'legacy'; + + public static function mapToMonologLevel(int|string $level): Level + { + $map = [ + // -vvv + OutputInterface::VERBOSITY_DEBUG => LogLevel::DEBUG, + self::DEBUG => LogLevel::DEBUG, + self::VVV => LogLevel::DEBUG, + + // -vv OR -v + OutputInterface::VERBOSITY_VERY_VERBOSE => LogLevel::INFO, + OutputInterface::VERBOSITY_VERBOSE => LogLevel::INFO, + self::INFO => LogLevel::INFO, + self::VV => LogLevel::INFO, + self::V => LogLevel::INFO, + self::Q => LogLevel::INFO, + + // Regular + OutputInterface::VERBOSITY_NORMAL => LogLevel::NOTICE, + self::DEFAULT => LogLevel::NOTICE, + + // Different level of issues + self::WARNING => LogLevel::WARNING, + self::LEGACY => LogLevel::WARNING, + self::E => LogLevel::ERROR, + self::ERROR => LogLevel::ERROR, + self::EXCEPTION => LogLevel::CRITICAL, + ]; + + return Level::fromName($map[$level] ?? LogLevel::INFO); + } + + public static function isPsrErrorLevel(Level $monologLevel): bool + { + return \in_array($monologLevel, [ + Level::Emergency, + Level::Alert, + Level::Critical, + Level::Error, + ], true); + } } diff --git a/src/OutputMods/AbstractOutputMode.php b/src/OutputMods/AbstractOutputMode.php new file mode 100644 index 0000000..445e541 --- /dev/null +++ b/src/OutputMods/AbstractOutputMode.php @@ -0,0 +1,281 @@ +prevMemory = \memory_get_usage(false); + $this->startTimer = \microtime(true); + $this->prevTime = $this->startTimer; + + $this->application = $application; + + $this->input = $input; + $this->output = $output; + + self::$instance = $this; + } + + public function getStartTime(): float + { + return $this->startTimer; + } + + public function getInput(): InputInterface + { + return $this->input; + } + + public function getOutput(): OutputInterface + { + return $this->output; + } + + public function getErrOutput(): OutputInterface + { + if ($this->isStdoutOnly()) { + return $this->output; + } + + return $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + } + + /** + * Alias to write new line in std output. + * @SuppressWarnings(PHPMD.CamelCaseMethodName) + */ + public function _( + iterable|float|int|bool|string|null $messages = '', + string $verboseLevel = OutLvl::DEFAULT, + array $context = [], + ): void { + $message = $this->prepareMessages($messages, $verboseLevel); + $context = $this->prepareContext($context); + + if ($message === null) { + return; + } + + if ( + $this->catchMode + && !bool(\preg_match('/^Working on ".*"\./', $message)) // hack for system messages + ) { + $this->caughtMessages[] = $message; + + return; + } + + $this->printMessage($message, $verboseLevel, $context); + } + + public function isOutputHasErrors(): bool + { + return $this->outputHasErrors; + } + + public function isStdoutOnly(): bool + { + return bool($this->input->getOption('stdout-only')); + } + + public function isDisplayProfiling(): bool + { + return bool($this->input->getOption('profile')); + } + + public function isDisplayTimestamp(): bool + { + return bool($this->input->getOption('timestamp')); + } + + public function isInfoLevel(): bool + { + return $this->getOutput()->isVerbose(); + } + + public function isWarningLevel(): bool + { + return $this->getOutput()->isVeryVerbose(); + } + + public function isDebugLevel(): bool + { + return $this->getOutput()->isDebug(); + } + + public function isProgressBarDisabled(): bool + { + return bool($this->getInput()->getOption('no-progress')); + } + + public function onExecBefore(): void + { + // empty + } + + public function onExecException(\Exception $exception): void + { + $this->_($exception->getMessage(), OutLvl::ERROR); + } + + public function onExecAfter(int $exitCode, ?string $outputLevel = null): void + { + $outputLevel ??= OutLvl::DEBUG; + if ($this->isDisplayProfiling()) { + $outputLevel = OutLvl::DEFAULT; + } + + $this->_('Exit code: ' . $exitCode, $outputLevel); + } + + public function catchModeStart(): void + { + \ob_start(); + $this->catchMode = true; + } + + public function catchModeFinish(): array + { + $echoOutput = \ob_get_clean(); + if (!isStrEmpty($echoOutput)) { + $this->caughtMessages[] = $echoOutput; + } + + $this->catchMode = false; + + $caughtMessages = $this->caughtMessages; + + $this->caughtMessages = []; + + return $caughtMessages; + } + + /** + * @deprecated + */ + public static function getInstance(): self + { + return self::$instance; + } + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + protected function getProfileInfo(): array + { + $currentTime = \microtime(true); + $currentMemory = \memory_get_usage(false); + + $startTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? 0.0; + + $result = [ + 'memory_usage_real' => \memory_get_usage(true), + 'memory_usage' => $currentMemory, + 'memory_usage_diff' => $currentMemory - $this->prevMemory, + 'memory_pick_real' => \memory_get_peak_usage(true), + 'memory_pick' => \memory_get_peak_usage(false), + 'time_total_ms' => \round(1000 * ($currentTime - $startTime), 3), + 'time_diff_ms' => \round(1000 * ($currentTime - $this->prevTime), 3), + ]; + + $this->prevTime = $currentTime; + $this->prevMemory = $currentMemory; + + return $result; + } + + protected function prepareMessages(iterable|float|int|bool|string|null $messages, string $verboseLevel): ?string + { + $verboseLevel = \strtolower(\trim($verboseLevel)); + + if (\is_iterable($messages)) { + foreach ($messages as $message) { + $this->_($message, $verboseLevel); + } + + return null; + } + + if ($messages === null) { + $messages = 'null'; + } elseif (\is_bool($messages)) { + $messages = $messages ? 'true' : 'false'; + } + + $messages = (string)$messages; + + if (\str_contains($messages, "\n")) { + $this->_(\explode("\n", $messages), $verboseLevel); + + return null; + } + + return $messages; + } + + protected function prepareContext(array $context): array + { + return (array)(new NormalizerFormatter())->normalizeValue($context); + } + + protected function markOutputHasErrors(bool $hasError = true): void + { + $this->outputHasErrors = $hasError; + } +} diff --git a/src/OutputMods/Cron.php b/src/OutputMods/Cron.php new file mode 100644 index 0000000..f30af4a --- /dev/null +++ b/src/OutputMods/Cron.php @@ -0,0 +1,75 @@ +getFormatter()->setDecorated(false); + if ($output->getVerbosity() < OutputInterface::VERBOSITY_VERY_VERBOSE) { + $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); + } + + parent::__construct($input, $output, $application); + } + + public function isStdoutOnly(): bool + { + return true; + } + + public function isDisplayProfiling(): bool + { + return true; + } + + public function isDisplayTimestamp(): bool + { + return true; + } + + public function isInfoLevel(): bool + { + return true; + } + + public function isWarningLevel(): bool + { + return true; + } + + public function isProgressBarDisabled(): bool + { + return true; + } + + public static function getName(): string + { + return 'cron'; + } + + public static function getDescription(): string + { + return "Shortcut for crontab. It's basically focused on human-readable logs output.\n" + . " It's combination of --timestamp --profile --stdout-only --no-progress -vv."; + } +} diff --git a/src/OutputMods/Exception.php b/src/OutputMods/Exception.php new file mode 100644 index 0000000..b74142c --- /dev/null +++ b/src/OutputMods/Exception.php @@ -0,0 +1,21 @@ +getFormatter()->setDecorated(false); + + $handler = new StreamHandler('php://stdout', OutLvl::mapToMonologLevel($output->getVerbosity())); + $handler->setFormatter(new LogstashFormatter('cli')); + + $this->logger = new Logger(Slug::filter($application->getName())); + $this->logger->pushHandler($handler); + + parent::__construct($input, $output, $application); + } + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + public function onExecBefore(): void + { + $this->_('Command Start: ' . (string)$this->input->getFirstArgument(), OutLvl::INFO, [ + 'service' => [ + 'name' => $this->application->getName(), + 'version' => $this->application->getVersion(), + 'type' => 'php', + ], + 'process' => [ + 'pid' => \getmypid(), + 'executable' => $_SERVER['PHP_SELF'] ?? null, + 'args_count' => $_SERVER['argv'] ?? null, + 'command_line' => $this->input->__toString(), + 'process_command' => $this->input->getFirstArgument(), + 'args' => $this->input->getArguments() + $this->input->getOptions(), + 'working_directory' => \getcwd(), + ], + ]); + } + + public function onExecException(\Exception $exception): void + { + $this->logger->log( + Level::Critical, + 'Command Exception: ' . $exception->getMessage(), + $this->prepareContext(['error' => self::exceptionToLog($exception)]), + ); + } + + public function onExecAfter(int $exitCode, ?string $outputLevel = null): void + { + $outputLevel ??= OutLvl::INFO; + $this->_('Command Finish: ExitCode=' . $exitCode, $outputLevel, [ + 'process' => ['exit_code' => $exitCode], + ]); + } + + public function isProgressBarDisabled(): bool + { + return false; + } + + public function createProgressBar(): AbstractProgressBar + { + return new ProgressBarLight($this); + } + + public static function getName(): string + { + return 'logstash'; + } + + public static function getDescription(): string + { + return 'Logstash output format, for integration with ELK stack.'; + } + + protected function printMessage( + ?string $message = '', + string $verboseLevel = OutLvl::DEFAULT, + array $context = [], + ): void { + $nonZeroOnError = bool($this->getInput()->getOption('non-zero-on-error')); + $psrErrorLevel = OutLvl::mapToMonologLevel($verboseLevel); + + if ($nonZeroOnError && OutLvl::isPsrErrorLevel($psrErrorLevel)) { + $this->markOutputHasErrors(true); + } + + if ($message !== null && $message !== '') { + $this->logger->log($psrErrorLevel, \strip_tags($message), $context); + } + } + + protected function prepareContext(array $context): array + { + $newContext = [ + 'trace' => ['id' => CliHelper::createOrGetTraceId()], + 'profile' => $this->getProfileInfo(), + ] + $context; + + return parent::prepareContext($newContext); + } + + private static function exceptionToLog(?\Throwable $exception): ?array + { + static $deepCounter = 0; + + if ($exception === null) { + return null; + } + + $maxExceptionDeepLevel = 5; + $deepCounter++; + + if ($deepCounter === $maxExceptionDeepLevel) { + return [ + 'message' => $exception->getMessage(), + 'previous' => 'too deep', + ]; + } + + return [ + 'type' => \get_class($exception), + 'code' => $exception->getCode(), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile() . ':' . $exception->getLine(), + 'stack_trace' => $exception->getTraceAsString(), + 'previous' => self::exceptionToLog($exception->getPrevious()), + ]; + } +} diff --git a/src/OutputMods/Text.php b/src/OutputMods/Text.php new file mode 100644 index 0000000..accd959 --- /dev/null +++ b/src/OutputMods/Text.php @@ -0,0 +1,187 @@ +getOutput()); + self::addOutputStyles($this->getErrOutput()); + + if ($this->output instanceof ConsoleOutput && $this->isStdoutOnly()) { + $this->output->setErrorOutput($this->output); + } + } + + public function onExecBefore(): void + { + $this->_('Working Directory is ' . \getcwd() . '', OutLvl::DEBUG); + } + + public function onExecAfter(int $exitCode, ?string $outputLevel = null): void + { + $outputLevel ??= OutLvl::DEBUG; + if ($this->isDisplayProfiling()) { + $outputLevel = OutLvl::DEFAULT; + } + + $totalTime = \number_format(\microtime(true) - $this->getStartTime(), 3); + $curMemory = FS::format(\memory_get_usage(false)); + $maxMemory = FS::format(\memory_get_peak_usage(true)); + + $this->_( + \implode('; ', [ + "Memory Usage/Peak: {$curMemory}/{$maxMemory}", + "Execution Time: {$totalTime} sec", + ]), + $outputLevel, + ); + + $this->_("Exit Code is \"{$exitCode}\"", $outputLevel); + } + + public function onExecException(\Exception $exception): void + { + if (bool($this->getInput()->getOption('mute-errors'))) { + $this->_($exception->getMessage(), OutLvl::EXCEPTION); + } + } + + public function createProgressBar(): AbstractProgressBar + { + return new ProgressBarSymfony($this); + } + + public static function getName(): string + { + return 'text'; + } + + public static function getDescription(): string + { + return 'Default text output format, userfriendly and easy to read.'; + } + + public static function addOutputStyles(OutputInterface $output): void + { + $formatter = $output->getFormatter(); + $defaultColor = 'default'; + + $colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', $defaultColor]; + + foreach ($colors as $color) { + $formatter->setStyle($color, new OutputFormatterStyle($color)); + $formatter->setStyle("{$color}-b", new OutputFormatterStyle($color, null, ['bold'])); + $formatter->setStyle("{$color}-u", new OutputFormatterStyle($color, null, ['underscore'])); + $formatter->setStyle("{$color}-r", new OutputFormatterStyle($color, null, ['reverse'])); + $formatter->setStyle("{$color}-bg", new OutputFormatterStyle(null, $color)); + $formatter->setStyle("{$color}-bl", new OutputFormatterStyle($color, null, ['blink'])); + } + + $formatter->setStyle('bl', new OutputFormatterStyle($defaultColor, null, ['blink'])); + $formatter->setStyle('b', new OutputFormatterStyle($defaultColor, null, ['bold'])); + $formatter->setStyle('u', new OutputFormatterStyle($defaultColor, null, ['underscore'])); + $formatter->setStyle('r', new OutputFormatterStyle(null, null, ['reverse'])); + $formatter->setStyle('bg', new OutputFormatterStyle('black', 'white')); + + // Aliases + $formatter->setStyle('i', new OutputFormatterStyle('green')); // Alias for + $formatter->setStyle('c', new OutputFormatterStyle('yellow')); // Alias for + $formatter->setStyle('q', new OutputFormatterStyle('black', 'cyan')); // Alias for + $formatter->setStyle('e', new OutputFormatterStyle('white', 'red')); // Alias for + + $output->setFormatter($formatter); + } + + /** + * Alias to write new line in std output. + */ + protected function printMessage( + string $message = '', + string $verboseLevel = OutLvl::DEFAULT, + array $context = [], + ): void { + if (\count($context) > 0) { + $message .= ' ' . \json_encode($context, \JSON_THROW_ON_ERROR); + } + + $profilePrefix = ''; + + if ($this->isDisplayTimestamp()) { + $timestamp = (new \DateTimeImmutable())->format($this->timestampFormat); + $profilePrefix .= "[{$timestamp}] "; + } + + if ($this->isDisplayProfiling()) { + $profile = $this->getProfileInfo(); + $memoryDiff = FS::format($profile['memory_usage_diff']); + $totalTime = \number_format($profile['time_diff_ms'] / 1000, 3); + $curMemory = \str_pad($memoryDiff, 10, ' ', \STR_PAD_LEFT); + + $profilePrefix .= "[+{$totalTime}s/{$curMemory}] "; + } + + $vNormal = OutputInterface::VERBOSITY_NORMAL; + + if ($verboseLevel === OutLvl::DEFAULT) { + $this->getOutput()->writeln($profilePrefix . $message, $vNormal); + } elseif ($verboseLevel === OutLvl::V) { + $this->getOutput()->writeln($profilePrefix . $message, OutputInterface::VERBOSITY_VERBOSE); + } elseif ($verboseLevel === OutLvl::VV) { + $this->getOutput()->writeln($profilePrefix . $message, OutputInterface::VERBOSITY_VERY_VERBOSE); + } elseif ($verboseLevel === OutLvl::VVV) { + $this->getOutput()->writeln($profilePrefix . $message, OutputInterface::VERBOSITY_DEBUG); + } elseif ($verboseLevel === OutLvl::Q) { + $this->getOutput()->writeln($profilePrefix . $message, OutputInterface::VERBOSITY_QUIET); // Show ALWAYS! + } elseif ($verboseLevel === OutLvl::LEGACY) { + $this->_('Legacy Output: ' . $message); + } elseif ($verboseLevel === OutLvl::DEBUG) { + $this->_('Debug: ' . $message, OutLvl::VVV); + } elseif ($verboseLevel === OutLvl::WARNING) { + $this->_('Warning: ' . $message, OutLvl::VV); + } elseif ($verboseLevel === OutLvl::INFO) { + $this->_('Info: ' . $message, OutLvl::V); + } elseif ($verboseLevel === OutLvl::E) { + $this->markOutputHasErrors(true); + $this->getErrOutput()->writeln($profilePrefix . $message, $vNormal); + } elseif ($verboseLevel === OutLvl::ERROR) { + $this->markOutputHasErrors(true); + $this->getErrOutput()->writeln($profilePrefix . 'Error: ' . $message, $vNormal); + } elseif ($verboseLevel === OutLvl::EXCEPTION) { + $this->markOutputHasErrors(true); + $this->getErrOutput()->writeln($profilePrefix . 'Muted Exception: ' . $message, $vNormal); + } else { + throw new Exception("Undefined verbose level: \"{$verboseLevel}\""); + } + } +} diff --git a/src/ProgressBars/AbstractProgressBar.php b/src/ProgressBars/AbstractProgressBar.php index 03258c3..e53babf 100644 --- a/src/ProgressBars/AbstractProgressBar.php +++ b/src/ProgressBars/AbstractProgressBar.php @@ -16,146 +16,96 @@ namespace JBZoo\Cli\ProgressBars; -use JBZoo\Utils\Arr; -use JBZoo\Utils\Dates; -use JBZoo\Utils\FS; -use JBZoo\Utils\Stats; -use JBZoo\Utils\Sys; -use Symfony\Component\Console\Helper\Helper as SymfonyHelper; -use Symfony\Component\Console\Helper\ProgressBar as SymfonyProgressBar; +use JBZoo\Cli\OutputMods\AbstractOutputMode; abstract class AbstractProgressBar { - public const BREAK = 'Progress stopped'; - public const MAX_LINE_LENGTH = 80; + protected AbstractOutputMode $outputMode; - /** @var int[] */ - protected array $stepMemoryDiff = []; + protected ?\Closure $callback = null; - /** @var float[] */ - protected array $stepTimers = []; + protected bool $throwBatchException = true; - abstract protected function buildTemplate(): string; + protected int $max = 0; + protected iterable $list = []; + protected string $title = ''; + protected int $nestedLevel = 0; - public static function setPlaceholder(string $name, callable $callable): void + protected ?\Closure $callbackOnStart = null; + protected ?\Closure $callbackOnFinish = null; + + abstract public function execute(): bool; + + public function __construct(AbstractOutputMode $outputMode) { - SymfonyProgressBar::setPlaceholderFormatterDefinition($name, $callable); + $this->outputMode = $outputMode; } - /** - * @SuppressWarnings(PHPMD.NPathComplexity) - */ - protected function configureProgressBar(): void + public function setTitle(string $title): self { - static $inited; + $this->title = $title; - if ($inited) { - return; + return $this; + } + + public function setList(iterable $list): self + { + $this->list = $list; + + if ($list instanceof \Countable) { + $this->max = \count($list); + } elseif (\is_array($list)) { + $this->max = \count($list); } - $inited = true; - - // Memory - self::setPlaceholder( - 'jbzoo_memory_current', - static fn (): string => SymfonyHelper::formatMemory(\memory_get_usage(false)), - ); - - self::setPlaceholder( - 'jbzoo_memory_peak', - static fn (): string => SymfonyHelper::formatMemory(\memory_get_peak_usage(false)), - ); - - self::setPlaceholder('jbzoo_memory_limit', static fn (): string => Sys::iniGet('memory_limit')); - - self::setPlaceholder('jbzoo_memory_step_avg', function (SymfonyProgressBar $bar): string { - if ( - $bar->getMaxSteps() === 0 - || $bar->getProgress() === 0 - || \count($this->stepMemoryDiff) === 0 - ) { - return 'n/a'; - } - - return FS::format((int)Stats::mean($this->stepMemoryDiff)); - }); - - self::setPlaceholder('jbzoo_memory_step_last', function (SymfonyProgressBar $bar): string { - if ( - $bar->getMaxSteps() === 0 - || $bar->getProgress() === 0 - || \count($this->stepMemoryDiff) === 0 - ) { - return 'n/a'; - } - - return FS::format(Arr::last($this->stepMemoryDiff)); - }); - - // Timers - self::setPlaceholder( - 'jbzoo_time_elapsed', - static fn (SymfonyProgressBar $bar): string => Dates::formatTime(\time() - $bar->getStartTime()), - ); - - self::setPlaceholder('jbzoo_time_remaining', static function (SymfonyProgressBar $bar): string { - if ($bar->getMaxSteps() === 0) { - return 'n/a'; - } - - if ($bar->getProgress() === 0) { - $remaining = 0; - } else { - $remaining = \round( - (\time() - $bar->getStartTime()) - / $bar->getProgress() - * ($bar->getMaxSteps() - $bar->getProgress()), - ); - } - - return Dates::formatTime($remaining); - }); - - self::setPlaceholder('jbzoo_time_estimated', static function (SymfonyProgressBar $bar): string { - if ($bar->getMaxSteps() === 0) { - return 'n/a'; - } - - if ($bar->getProgress() === 0) { - $estimated = 0; - } else { - $estimated = \round( - (\time() - $bar->getStartTime()) - / $bar->getProgress() - * $bar->getMaxSteps(), - ); - } - - return Dates::formatTime($estimated); - }); - - self::setPlaceholder('jbzoo_time_step_avg', function (SymfonyProgressBar $bar): string { - if ( - $bar->getMaxSteps() === 0 - || $bar->getProgress() === 0 - || \count($this->stepTimers) === 0 - ) { - return 'n/a'; - } - - return \str_replace('±', '±', Stats::renderAverage($this->stepTimers)) . ' sec'; - }); - - self::setPlaceholder('jbzoo_time_step_last', function (SymfonyProgressBar $bar): string { - if ( - $bar->getMaxSteps() === 0 - || $bar->getProgress() === 0 - || \count($this->stepTimers) === 0 - ) { - return 'n/a'; - } - - return Dates::formatTime(Arr::last($this->stepTimers)); - }); + return $this; + } + + public function setCallback(\Closure $callback): self + { + $this->callback = $callback; + + return $this; + } + + public function setThrowBatchException(bool $throwBatchException): self + { + $this->throwBatchException = $throwBatchException; + + return $this; + } + + public function setMax(int $max): self + { + $this->max = $max; + $this->list = \range(0, $max - 1); + + return $this; + } + + public function onStart(\Closure $callback): self + { + $this->callbackOnStart = $callback; + + return $this; + } + + public function onFinish(\Closure $callback): self + { + $this->callbackOnFinish = $callback; + + return $this; + } + + public function setNextedLevel(int $nestedLevel): self + { + $this->nestedLevel = $nestedLevel; + + return $this; + } + + public function getNextedLevel(): int + { + return $this->nestedLevel; } } diff --git a/src/ProgressBars/AbstractSymfonyProgressBar.php b/src/ProgressBars/AbstractSymfonyProgressBar.php new file mode 100644 index 0000000..d3e6f07 --- /dev/null +++ b/src/ProgressBars/AbstractSymfonyProgressBar.php @@ -0,0 +1,152 @@ + Dates::formatTime(\time() - $bar->getStartTime()), + ); + + self::setPlaceholder('jbzoo_time_estimated', static function (SymfonyProgressBar $bar): string { + if ($bar->getMaxSteps() === 0) { + return 'n/a'; + } + + if ($bar->getProgress() === 0) { + $estimated = 0; + } else { + $estimated = \round((\time() - $bar->getStartTime()) / $bar->getProgress() * $bar->getMaxSteps()); + } + + return Dates::formatTime($estimated); + }); + + self::setPlaceholder( + 'jbzoo_memory_current', + static fn (): string => SymfonyHelper::formatMemory(\memory_get_usage(false)), + ); + + // Memory/Time optimizations + if ($optimizeMode) { + return; + } + + self::setPlaceholder( + 'jbzoo_memory_peak', + static fn (): string => SymfonyHelper::formatMemory(\memory_get_peak_usage(false)), + ); + + self::setPlaceholder('jbzoo_memory_limit', static fn (): string => Sys::iniGet('memory_limit')); + + self::setPlaceholder('jbzoo_memory_step_median', function (SymfonyProgressBar $bar): string { + if ( + $bar->getMaxSteps() === 0 + || $bar->getProgress() === 0 + || \count($this->stepMemoryDiff) === 0 + ) { + return 'n/a'; + } + + return FS::format((int)Stats::median($this->stepMemoryDiff)); + }); + + self::setPlaceholder('jbzoo_memory_step_last', function (SymfonyProgressBar $bar): string { + if ( + $bar->getMaxSteps() === 0 + || $bar->getProgress() === 0 + || \count($this->stepMemoryDiff) === 0 + ) { + return 'n/a'; + } + + return FS::format(Arr::last($this->stepMemoryDiff)); + }); + + self::setPlaceholder('jbzoo_time_remaining', static function (SymfonyProgressBar $bar): string { + if ($bar->getMaxSteps() === 0) { + return 'n/a'; + } + + if ($bar->getProgress() === 0) { + $remaining = 0; + } else { + $remaining = \round( + (\time() - $bar->getStartTime()) + / $bar->getProgress() + * ($bar->getMaxSteps() - $bar->getProgress()), + ); + } + + return Dates::formatTime($remaining); + }); + + self::setPlaceholder('jbzoo_time_step_median', function (SymfonyProgressBar $bar): string { + if ( + $bar->getMaxSteps() === 0 + || $bar->getProgress() === 0 + || \count($this->stepTimers) === 0 + ) { + return 'n/a'; + } + + return \str_replace('±', '±', Stats::renderMedian($this->stepTimers)) . ' sec'; + }); + + self::setPlaceholder('jbzoo_time_step_last', function (SymfonyProgressBar $bar): string { + if ( + $bar->getMaxSteps() === 0 + || $bar->getProgress() === 0 + || \count($this->stepTimers) === 0 + ) { + return 'n/a'; + } + + return Dates::formatTime(Arr::last($this->stepTimers)); + }); + } +} diff --git a/src/ProgressBars/ExceptionBreak.php b/src/ProgressBars/ExceptionBreak.php new file mode 100644 index 0000000..1ca69be --- /dev/null +++ b/src/ProgressBars/ExceptionBreak.php @@ -0,0 +1,22 @@ +progressBar()` instead of ProgressBar::run() + */ +class ProgressBar extends ProgressBarSymfony { - private OutputInterface $output; - private string $title = ''; - private ?SymfonyProgressBar $progressBar = null; - private Cli $helper; - private int $max = 0; - private ?\Closure $callback = null; - private bool $throwBatchException = true; - private string $finishIcon; - private string $progressIcon; - - /** @var array|iterable */ - private iterable $list = []; - - public function __construct(?OutputInterface $output = null) - { - $this->helper = Cli::getInstance(); - $this->output = $output ?? $this->helper->getOutput(); - - $this->progressIcon = Icons::getRandomIcon(Icons::GROUP_PROGRESS, $this->output->isDecorated()); - $this->finishIcon = Icons::getRandomIcon(Icons::GROUP_FINISH, $this->output->isDecorated()); - } - - public function setTitle(string $title): self - { - $this->title = $title; - - return $this; - } - - public function setList(iterable $list): self - { - $this->list = $list; - - if ($list instanceof \Countable) { - $this->max = \count($list); - } elseif (\is_array($list)) { - $this->max = \count($list); - } - - return $this; - } - - public function setMax(int $max): self - { - $this->max = $max; - $this->list = \range(0, $max - 1); - - return $this; - } - - public function setCallback(\Closure $callback): self - { - $this->callback = $callback; - - return $this; - } - - public function setThrowBatchException(bool $throwBatchException): self - { - $this->throwBatchException = $throwBatchException; - - return $this; - } - - public function getProgressBar(): ?SymfonyProgressBar - { - return $this->progressBar; - } - - public function init(): bool - { - if ($this->max <= 0) { - $this->helper->_( - !isStrEmpty($this->title) - ? "{$this->title}. Number of items is 0 or less" - : 'Number of items is 0 or less', - ); - - return false; - } - - $this->progressBar = $this->createProgressBar(); - if ($this->progressBar === null) { - $this->helper->_( - !isStrEmpty($this->title) - ? "Working on \"{$this->title}\". Number of steps: {$this->max}." - : "Number of steps: {$this->max}.", - ); - } - - return true; - } - - public function execute(): bool - { - if (!$this->init()) { - return false; - } - - $exceptionMessages = []; - $isSkipped = false; - - $currentIndex = 0; - - foreach ($this->list as $stepIndex => $stepValue) { - $this->setStep($stepIndex, $currentIndex); - - $startTime = \microtime(true); - $startMemory = \memory_get_usage(false); - - [$stepResult, $exceptionMessage] = $this->handleOneStep($stepValue, $stepIndex, $currentIndex); - - $this->stepMemoryDiff[] = \memory_get_usage(false) - $startMemory; - $this->stepTimers[] = \microtime(true) - $startTime; - - $exceptionMessages = \array_merge($exceptionMessages, (array)$exceptionMessage); - - if ($this->progressBar !== null) { - $errorNumbers = \count($exceptionMessages); - $errMessage = $errorNumbers > 0 ? "{$errorNumbers}" : '0'; - $this->progressBar->setMessage($errMessage, 'jbzoo_caught_exceptions'); - } - - if (\str_contains($stepResult, self::BREAK)) { - $isSkipped = true; - break; - } - - $currentIndex++; - } - - if ($this->progressBar !== null) { - if ($isSkipped) { - $this->progressBar->display(); - } else { - $this->progressBar->finish(); - } - } - - self::showListOfExceptions($exceptionMessages); - $this->helper->_(''); - - return true; - } - - /** - * @param array|int|iterable $listOrMax - */ public static function run( - $listOrMax, + iterable|int $listOrMax, \Closure $callback, string $title = '', bool $throwBatchException = true, - ?OutputInterface $output = null, - ): self { - $progress = (new self($output)) - ->setTitle($title) - ->setCallback($callback) - ->setThrowBatchException($throwBatchException); - - if (\is_iterable($listOrMax)) { - $progress->setList($listOrMax); - } else { - $progress->setMax($listOrMax); - } - - $progress->execute(); - - return $progress; - } - - protected function buildTemplate(): string - { - $progressBarLines = []; - $footerLine = []; - - $bar = '[%bar%]'; - $percent = '%percent:2s%%'; - $steps = '(%current% / %max%)'; - - if (!isStrEmpty($this->title)) { - $progressBarLines[] = "Progress of {$this->title}"; - } - - if ($this->output->isVeryVerbose()) { - $progressBarLines[] = \implode(' ', [ - $percent, - $steps, - $bar, - $this->finishIcon, - ]); - $footerLine['Time (pass/left/est/avg/last)'] = \implode(' / ', [ - '%jbzoo_time_elapsed:9s%', - '%jbzoo_time_remaining:8s%', - '%jbzoo_time_estimated:8s%', - '%jbzoo_time_step_avg%', - '%jbzoo_time_step_last%', - ]); - $footerLine['Memory (cur/peak/limit/leak/last)'] = \implode(' / ', [ - '%jbzoo_memory_current:8s%', - '%jbzoo_memory_peak%', - '%jbzoo_memory_limit%', - '%jbzoo_memory_step_avg%', - '%jbzoo_memory_step_last%', - ]); - $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; - } elseif ($this->output->isVerbose()) { - $progressBarLines[] = \implode(' ', [ - $percent, - $steps, - $bar, - $this->finishIcon, - '%jbzoo_memory_current:8s%', - ]); - - $footerLine['Time (pass/left/est)'] = \implode(' / ', [ - '%jbzoo_time_elapsed:8s%', - '%jbzoo_time_remaining:8s%', - '%jbzoo_time_estimated%', - ]); - $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; - } else { - $progressBarLines[] = \implode(' ', [ - $percent, - $steps, - $bar, - $this->finishIcon, - '%jbzoo_time_elapsed:8s%/%jbzoo_time_estimated% | %jbzoo_memory_current%', - ]); - } - - $footerLine['Last Step Message'] = '%message%'; - - return \implode("\n", $progressBarLines) . "\n" . CliRender::list($footerLine) . "\n"; - } - - private function setStep(int|float|string $stepIndex, int $currentIndex): void - { - if ($this->progressBar !== null) { - $this->progressBar->setProgress($currentIndex); - $this->progressBar->setMessage($stepIndex . ': ', 'jbzoo_current_index'); - } - } - - private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, int $currentIndex): array - { - if ($this->callback === null) { - throw new Exception('Callback function is not defined'); - } - - $exceptionMessage = null; - $prefixMessage = $stepIndex === $currentIndex ? $currentIndex : "{$stepIndex}/{$currentIndex}"; - $callbackResults = []; - - // Executing callback - try { - $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); - } catch (\Exception $exception) { - if ($this->throwBatchException) { - $errorMessage = 'Error. ' . $exception->getMessage(); - $callbackResults[] = $errorMessage; - $exceptionMessage = " * ({$prefixMessage}): {$errorMessage}"; - } else { - throw $exception; - } - } - - // Handle status messages - $stepResult = ''; - if (\count($callbackResults) > 0) { - $stepResult = \str_replace(["\n", "\r", "\t"], ' ', \implode('; ', $callbackResults)); - - if ($this->progressBar !== null) { - if (\strlen(\strip_tags($stepResult)) > self::MAX_LINE_LENGTH) { - $stepResult = Str::limitChars(\strip_tags($stepResult), self::MAX_LINE_LENGTH); - } - - $this->progressBar->setMessage($stepResult); - } else { - $this->helper->_(" * ({$prefixMessage}): {$stepResult}"); - } - } elseif ($this->progressBar === null) { - $this->helper->_(" * ({$prefixMessage}): n/a"); - } - - return [$stepResult, $exceptionMessage]; - } - - private function createProgressBar(): ?SymfonyProgressBar - { - if ($this->helper->isProgressBarDisabled()) { - return null; - } - - $this->configureProgressBar(); - - $progressBar = new SymfonyProgressBar($this->output, $this->max); - - $progressBar->setBarCharacter(''); - $progressBar->setEmptyBarCharacter('_'); - $progressBar->setProgressCharacter($this->progressIcon); - $progressBar->setBarWidth($this->output->isVerbose() ? 70 : 40); - $progressBar->setFormat($this->buildTemplate()); - - $progressBar->setMessage('n/a'); - $progressBar->setMessage('0', 'jbzoo_caught_exceptions'); - $progressBar->setProgress(0); - $progressBar->setOverwrite(true); - - $progressBar->setRedrawFrequency(1); - $progressBar->minSecondsBetweenRedraws(0.5); - $progressBar->maxSecondsBetweenRedraws(1.5); - - return $progressBar; - } - - private static function showListOfExceptions(array $exceptionMessages): void - { - if (\count($exceptionMessages) > 0) { - $listOfErrors = \implode("\n", $exceptionMessages) . "\n"; - $listOfErrors = \str_replace('Error. ', '', $listOfErrors); + ): void { + // get object of parent object where we call the static method + $backtrace = \debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + $command = $backtrace[1]['object'] ?? null; - throw new Exception("\n Error list:\n" . $listOfErrors); + if ($command instanceof CliCommand) { + $command->progressBar($listOrMax, $callback, $title, $throwBatchException); } } } diff --git a/src/ProgressBars/ProgressBarLight.php b/src/ProgressBars/ProgressBarLight.php new file mode 100644 index 0000000..320ea98 --- /dev/null +++ b/src/ProgressBars/ProgressBarLight.php @@ -0,0 +1,138 @@ +init()) { + return false; + } + + if ($this->callbackOnStart !== null) { + \call_user_func($this->callbackOnStart, $this); + } + + $exceptionMessages = []; + $currentIndex = 0; + + foreach ($this->list as $stepIndex => $stepValue) { + [$stepResult, $exceptionMessage] = $this->handleOneStep($stepValue, $stepIndex, $currentIndex); + + $exceptionMessages = \array_merge($exceptionMessages, (array)$exceptionMessage); + + $this->outputMode->_($stepResult); + + if (\is_string($stepResult) && \str_contains($stepResult, ExceptionBreak::MESSAGE)) { + break; + } + + $currentIndex++; + } + + if ($this->callbackOnFinish !== null) { + \call_user_func($this->callbackOnFinish, $this); + } + + self::showListOfExceptions($exceptionMessages); + + return true; + } + + private function init(): bool + { + $progresBarLevel = $this->getNextedLevel(); + $levelPostfix = $progresBarLevel > 1 ? " Level: {$progresBarLevel}." : ''; + + if ($this->max <= 0) { + if (isStrEmpty($this->title)) { + $this->outputMode->_("Number of items is 0 or less.{$levelPostfix}"); + } else { + $this->outputMode->_("{$this->title}. Number of items is 0 or less.{$levelPostfix}"); + } + + return false; + } + + if (isStrEmpty($this->title)) { + $this->outputMode->_("Number of steps: {$this->max}.{$levelPostfix}"); + } else { + $this->outputMode->_("Working on \"{$this->title}\". Number of steps: {$this->max}.{$levelPostfix}"); + } + + return true; + } + + private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, int $currentIndex): array + { + if ($this->callback === null) { + throw new Exception('Callback function is not defined'); + } + + $exceptionMessage = null; + + $humanIndex = $currentIndex + 1; + $prefixMessage = $stepIndex === $currentIndex + ? "Step={$humanIndex}/Max={$this->max}" + : "Key={$currentIndex}/Step={$humanIndex}/Max={$this->max}"; + + $callbackResults = []; + + $this->outputMode->catchModeStart(); + + try { + $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); + } catch (ExceptionBreak $exception) { + $callbackResults[] = ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); + } catch (\Exception $exception) { + if ($this->throwBatchException) { + $errorMessage = 'Exception: ' . $exception->getMessage(); + $callbackResults[] = $errorMessage; + $exceptionMessage = "({$prefixMessage}): {$errorMessage}"; + } else { + throw $exception; + } + } + + $caughtMessages = $this->outputMode->catchModeFinish(); + if (\count($caughtMessages) > 0) { + $callbackResults = \array_merge($callbackResults, $caughtMessages); + } + + // Handle status messages + if (\count($callbackResults) > 0) { + $stepResult = "({$prefixMessage}): " . \implode('; ', $callbackResults); + } else { + $stepResult = "({$prefixMessage}): Empty Output"; + } + + return [$stepResult, $exceptionMessage]; + } + + private static function showListOfExceptions(array $exceptionMessages): void + { + if (\count($exceptionMessages) > 0) { + $listOfErrors = \implode('; ', $exceptionMessages); + $listOfErrors = \str_replace('Exception: ', '', $listOfErrors); + + throw new Exception('BatchExceptions: ' . $listOfErrors); + } + } +} diff --git a/src/ProgressBars/ProgressBarProcessManager.php b/src/ProgressBars/ProgressBarProcessManager.php index 566a640..f763c65 100644 --- a/src/ProgressBars/ProgressBarProcessManager.php +++ b/src/ProgressBars/ProgressBarProcessManager.php @@ -18,19 +18,22 @@ use JBZoo\Cli\CliRender; use JBZoo\Cli\Icons; +use JBZoo\Cli\OutputMods\AbstractOutputMode; use Symfony\Component\Console\Helper\ProgressBar as SymfonyProgressBar; use Symfony\Component\Console\Output\OutputInterface; -class ProgressBarProcessManager extends AbstractProgressBar +class ProgressBarProcessManager extends AbstractSymfonyProgressBar { private OutputInterface $output; private SymfonyProgressBar $progressBar; - public function __construct(OutputInterface $output, int $maxCount = 0) + public function __construct(AbstractOutputMode $outputMode) { - $this->output = $output; - $this->progressBar = $this->createProgressBar($output, $maxCount); + parent::__construct($outputMode); + + $this->output = $outputMode->getOutput(); + $this->progressBar = $this->createProgressBar($this->output); } public function start(): void @@ -48,6 +51,11 @@ public function advance(): void $this->progressBar->advance(); } + public function execute(): bool + { + return true; + } + protected function buildTemplate(): string { $this->configureProgressBar(); diff --git a/src/ProgressBars/ProgressBarSymfony.php b/src/ProgressBars/ProgressBarSymfony.php new file mode 100644 index 0000000..6a5fd55 --- /dev/null +++ b/src/ProgressBars/ProgressBarSymfony.php @@ -0,0 +1,316 @@ +output = $outputMode->getOutput(); + + $this->progressIcon = Icons::getRandomIcon(Icons::GROUP_PROGRESS, $this->output->isDecorated()); + $this->finishIcon = Icons::getRandomIcon(Icons::GROUP_FINISH, $this->output->isDecorated()); + } + + /** + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function execute(): bool + { + if (!$this->init()) { + return false; + } + + $exceptionMessages = []; + $isSkipped = false; + + $currentIndex = 0; + + if ($this->callbackOnStart !== null) { + \call_user_func($this->callbackOnStart, $this); + } + + $isOptimizeMode = $this->isOptimizeMode(); + + foreach ($this->list as $stepIndex => $stepValue) { + $this->setStep($stepIndex, $currentIndex); + + $startTime = 0; + $startMemory = 0; + if (!$isOptimizeMode) { + $startTime = \microtime(true); + $startMemory = \memory_get_usage(false); + } + + [$stepResult, $exceptionMessage] = $this->handleOneStep($stepValue, $stepIndex, $currentIndex); + + if (!$isOptimizeMode) { + $this->stepMemoryDiff[] = \memory_get_usage(false) - $startMemory; + $this->stepTimers[] = \microtime(true) - $startTime; + } + + $exceptionMessages = \array_merge($exceptionMessages, (array)$exceptionMessage); + + if ($this->progressBar !== null) { + $errorNumbers = \count($exceptionMessages); + $errMessage = $errorNumbers > 0 ? "{$errorNumbers}" : '0'; + $this->progressBar->setMessage($errMessage, 'jbzoo_caught_exceptions'); + } + + if (\str_contains($stepResult, ExceptionBreak::MESSAGE)) { + $isSkipped = true; + break; + } + + $currentIndex++; + } + + if ($this->progressBar !== null) { + if ($isSkipped) { + $this->progressBar->display(); + } else { + $this->progressBar->finish(); + } + } + + if ($this->callbackOnFinish !== null) { + \call_user_func($this->callbackOnFinish, $this); + } + + self::showListOfExceptions($exceptionMessages); + + return true; + } + + protected function buildTemplate(): string + { + $progressBarLines = []; + $footerLine = []; + + $bar = '[%bar%]'; + $percent = '%percent:2s%%'; + $steps = '(%current% / %max%)'; + + if (!isStrEmpty($this->title)) { + $progressBarLines[] = "Progress of {$this->title}"; + } + + if ($this->output->isVeryVerbose()) { + $progressBarLines[] = \implode(' ', [$percent, $steps, $bar, $this->finishIcon]); + + $footerLine['Time (pass/left/est/median/last)'] = \implode(' / ', [ + '%jbzoo_time_elapsed:9s%', + '%jbzoo_time_remaining:8s%', + '%jbzoo_time_estimated:8s%', + '%jbzoo_time_step_median%', + '%jbzoo_time_step_last%', + ]); + + $footerLine['Mem (cur/peak/limit/leak/last)'] = \implode(' / ', [ + '%jbzoo_memory_current:8s%', + '%jbzoo_memory_peak%', + '%jbzoo_memory_limit%', + '%jbzoo_memory_step_median%', + '%jbzoo_memory_step_last%', + ]); + + $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; + } elseif ($this->output->isVerbose()) { + $progressBarLines[] = \implode(' ', [ + $percent, + $steps, + $bar, + $this->finishIcon, + '%jbzoo_memory_current:8s%', + ]); + + $footerLine['Time (pass/left/est)'] = \implode(' / ', [ + '%jbzoo_time_elapsed:8s%', + '%jbzoo_time_remaining:8s%', + '%jbzoo_time_estimated%', + ]); + + $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; + } else { + $progressBarLines[] = \implode(' ', [ + $percent, + $steps, + $bar, + $this->finishIcon, + '%jbzoo_time_elapsed:8s%/%jbzoo_time_estimated% | %jbzoo_memory_current%', + ]); + } + + $footerLine['Last Step Message'] = '%message%'; + + return \implode("\n", $progressBarLines) . "\n" . CliRender::list($footerLine) . "\n"; + } + + private function init(): bool + { + $progresBarLevel = $this->getNextedLevel(); + $levelPostfix = $progresBarLevel > 1 ? " Level: {$progresBarLevel}." : ''; + + if ($this->max <= 0) { + if (isStrEmpty($this->title)) { + $this->outputMode->_("Number of items is 0 or less.{$levelPostfix}"); + } else { + $this->outputMode->_("{$this->title}. Number of items is 0 or less.{$levelPostfix}"); + } + + return false; + } + + $this->progressBar = $this->createProgressBar(); + if ($this->progressBar === null) { + if (isStrEmpty($this->title)) { + $this->outputMode->_("Number of steps: {$this->max}.{$levelPostfix}"); + } else { + $this->outputMode->_( + "Working on \"{$this->title}\". " . + "Number of steps: {$this->max}.{$levelPostfix}", + ); + } + } + + return true; + } + + private function setStep(int|float|string $stepIndex, int $currentIndex): void + { + if ($this->progressBar !== null) { + $this->progressBar->setProgress($currentIndex); + $this->progressBar->setMessage($stepIndex . ': ', 'jbzoo_current_index'); + } + } + + private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, int $currentIndex): array + { + if ($this->callback === null) { + throw new Exception('Callback function is not defined'); + } + + $exceptionMessage = null; + $prefixMessage = $stepIndex === $currentIndex ? $currentIndex : "{$stepIndex}/{$currentIndex}"; + $callbackResults = []; + + $this->outputMode->catchModeStart(); + + // Executing callback + try { + $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); + } catch (ExceptionBreak $exception) { + $callbackResults[] = '' . ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); + } catch (\Exception $exception) { + if ($this->throwBatchException) { + $errorMessage = 'Exception: ' . $exception->getMessage(); + $callbackResults[] = $errorMessage; + $exceptionMessage = " * ({$prefixMessage}): {$errorMessage}"; + } else { + throw $exception; + } + } + + // Collect eventual output + $cathedMessages = $this->outputMode->catchModeFinish(); + if (\count($cathedMessages) > 0) { + $callbackResults = \array_merge($callbackResults, $cathedMessages); + } + + // Handle status messages + $stepResult = ''; + if (\count($callbackResults) > 0) { + $stepResult = \str_replace(["\n", "\r", "\t"], ' ', \implode('; ', $callbackResults)); + + if ($this->progressBar !== null) { + if (\strlen(\strip_tags($stepResult)) > self::MAX_LINE_LENGTH) { + $stepResult = Str::limitChars(\strip_tags($stepResult), self::MAX_LINE_LENGTH); + } + + $this->progressBar->setMessage($stepResult); + } else { + $this->outputMode->_(" * ({$prefixMessage}): {$stepResult}"); + } + } elseif ($this->progressBar === null) { + $this->outputMode->_(" * ({$prefixMessage}): n/a"); + } + + return [$stepResult, $exceptionMessage]; + } + + private function createProgressBar(): ?SymfonyProgressBar + { + if ($this->outputMode->isProgressBarDisabled()) { + return null; + } + + $this->configureProgressBar($this->isOptimizeMode()); + + $progressBar = new SymfonyProgressBar($this->output, $this->max); + + $progressBar->setBarCharacter(''); + $progressBar->setEmptyBarCharacter('_'); + $progressBar->setProgressCharacter($this->progressIcon); + $progressBar->setBarWidth($this->output->isVerbose() ? 70 : 40); + $progressBar->setFormat($this->buildTemplate()); + + $progressBar->setMessage('n/a'); + $progressBar->setMessage('0', 'jbzoo_caught_exceptions'); + $progressBar->setProgress(0); + $progressBar->setOverwrite(true); + + if (!$this->isOptimizeMode()) { + $progressBar->setRedrawFrequency(1); + $progressBar->minSecondsBetweenRedraws(0.5); + $progressBar->maxSecondsBetweenRedraws(1.5); + } + + return $progressBar; + } + + private function isOptimizeMode(): bool + { + return $this->outputMode->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL; + } + + private static function showListOfExceptions(array $exceptionMessages): void + { + if (\count($exceptionMessages) > 0) { + $listOfErrors = \implode("\n", $exceptionMessages) . "\n"; + $listOfErrors = \str_replace('Exception: ', '', $listOfErrors); + + throw new Exception("\n Error list:\n" . $listOfErrors); + } + } +} diff --git a/src/functions.php b/src/functions.php index afabf4c..9bcf708 100644 --- a/src/functions.php +++ b/src/functions.php @@ -16,11 +16,15 @@ namespace JBZoo\Cli; +use JBZoo\Cli\OutputMods\AbstractOutputMode; + /** * Shortcut method. - * @param array|mixed $messages + * @param mixed $messages + * @psalm-suppress DeprecatedMethod + * @suppress PhanDeprecatedFunction */ function cli($messages = '', string $verboseLevel = OutLvl::DEFAULT): void { - Cli::getInstance()->_($messages, $verboseLevel); + AbstractOutputMode::getInstance()->_($messages, $verboseLevel); } diff --git a/tests/CliMultiProcessTest.php b/tests/CliMultiProcessTest.php index 79134d8..be1653a 100644 --- a/tests/CliMultiProcessTest.php +++ b/tests/CliMultiProcessTest.php @@ -16,14 +16,13 @@ namespace JBZoo\PHPUnit; -use function JBZoo\Data\json; - class CliMultiProcessTest extends PHPUnit { public function testAsRealExecution(): void { - $start = \microtime(true); - $result = Helper::executeReal( + $start = \microtime(true); + + $cmdResult = Helper::executeReal( 'test:sleep-multi 123 " qwerty " -v', ['sleep' => 1, 'no-progress' => null, 'pm-max' => 50], 'JBZOO_TEST_VAR=123456', @@ -31,8 +30,8 @@ public function testAsRealExecution(): void $time = \microtime(true) - $start; - isSame(0, $result[0], $result[2]); - $outputAsArray = json($result[1])->getArrayCopy(); + isSame(0, $cmdResult->code, (string)$cmdResult); + $outputAsArray = $cmdResult->stdJson()->getArrayCopy(); $expectecContent = \implode("\n", [ 'Sleep : 1', @@ -48,19 +47,19 @@ public function testAsRealExecution(): void "Started: 3\n{$expectecContent}\nFinished: 3", "Started: 4\n{$expectecContent}\nFinished: 4", "Started: 5\n{$expectecContent}\nFinished: 5", - ], $outputAsArray); - isSame('', $result[2]); + ], $outputAsArray, (string)$cmdResult); + isSame('', $cmdResult->err, (string)$cmdResult); - isTrue($time < 5, "Total time: {$time}"); + isTrue($time < 6, "Total time: {$time}"); } public function testAsVirtalExecution(): void { - $start = \microtime(true); - $result = Helper::executeVirtaul('test:sleep-multi', ['sleep' => 1, 'no-progress' => null, 'pm-max' => 5]); - $time = \microtime(true) - $start; + $start = \microtime(true); + $cmdResult = Helper::executeVirtaul('test:sleep-multi', ['sleep' => 1, 'no-progress' => null, 'pm-max' => 5]); + $time = \microtime(true) - $start; - $outputAsArray = json($result)->getArrayCopy(); + $outputAsArray = $cmdResult->stdJson()->getArrayCopy(); $expectecContent = \implode("\n", [ 'Sleep : 1', @@ -78,19 +77,22 @@ public function testAsVirtalExecution(): void "Started: 5\n{$expectecContent}\nFinished: 5", ], $outputAsArray); - isTrue($time < 5, "Total time: {$time}"); + isTrue($time < 6, "Total time: {$time}"); } public function testException(): void { - $start = \microtime(true); - $result = Helper::executeReal( + $start = \microtime(true); + + $cmdResult = Helper::executeReal( 'test:sleep-multi 123 456 789', ['sleep' => 2, 'no-progress' => null, 'pm-max' => 5], ); $time = \microtime(true) - $start; - $outputAsArray = json($result[1])->getArrayCopy(); + $outputAsArray = $cmdResult->stdJson()->getArrayCopy(); + + isSame(1, $cmdResult->code); $expectecContent = \implode("\n", [ 'Sleep : 2', @@ -107,23 +109,23 @@ public function testException(): void "Started: 4\n{$expectecContent}\nFinished: 4", "Started: 5\n{$expectecContent}\nFinished: 5", ], $outputAsArray); - isContain('Exception messsage', $result[2]); + isContain('Exception messsage', $cmdResult->err); - isTrue($time < 5, "Total time: {$time}"); + isTrue($time < 6, "Total time: {$time}"); } public function testNumberOfCpuCores(): void { - $result = Helper::executeReal( + $cmdResult = Helper::executeReal( 'test:sleep-multi 123 " qwerty "', ['sleep' => 1, 'no-progress' => null, 'pm-max' => 50, '-vvv' => null], ); - isContain('Debug: Max number of sub-processes: 50', $result[1]); + isContain('Debug: Max number of sub-processes: 50', $cmdResult->std); isContain( 'Warning: The specified number of processes (--pm-max=50) ' . 'is more than the found number of CPU cores in the system', - $result[1], + $cmdResult->std, ); } } diff --git a/tests/CliOptionsTest.php b/tests/CliOptionsTest.php index ec501e7..3e068a4 100644 --- a/tests/CliOptionsTest.php +++ b/tests/CliOptionsTest.php @@ -16,14 +16,13 @@ namespace JBZoo\PHPUnit; -use function JBZoo\Data\json; - class CliOptionsTest extends PHPUnit { public function testOptionNone(): void { $option = 'opt-none'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => false, 'Bool' => false, @@ -31,9 +30,9 @@ public function testOptionNone(): void 'Float' => 0, 'String' => '', 'Array' => [false], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}"); isSame([ 'Default' => true, 'Bool' => true, @@ -41,13 +40,13 @@ public function testOptionNone(): void 'Float' => 1, 'String' => '1', 'Array' => [true], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionRequired(): void { - $option = 'opt-req'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-req'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => null, 'Bool' => false, @@ -55,9 +54,9 @@ public function testOptionRequired(): void 'Float' => 0, 'String' => '', 'Array' => [], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5'"); isSame([ 'Default' => '123.5', 'Bool' => true, @@ -65,9 +64,9 @@ public function testOptionRequired(): void 'Float' => 123.5, 'String' => '123.5', 'Array' => ['123.5'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false'"); isSame([ 'Default' => 'false', 'Bool' => false, @@ -75,13 +74,13 @@ public function testOptionRequired(): void 'Float' => 0, 'String' => 'false', 'Array' => ['false'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionRequiredAndDefault(): void { - $option = 'opt-req-default'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-req-default'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => '456.8', 'Bool' => true, @@ -89,9 +88,9 @@ public function testOptionRequiredAndDefault(): void 'Float' => 456.8, 'String' => '456.8', 'Array' => ['456.8'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5'"); isSame([ 'Default' => '123.5', 'Bool' => true, @@ -99,9 +98,9 @@ public function testOptionRequiredAndDefault(): void 'Float' => 123.5, 'String' => '123.5', 'Array' => ['123.5'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='0'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='0'"); isSame([ 'Default' => '0', 'Bool' => false, @@ -109,9 +108,9 @@ public function testOptionRequiredAndDefault(): void 'Float' => 0, 'String' => '0', 'Array' => ['0'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false'"); isSame([ 'Default' => 'false', 'Bool' => false, @@ -119,13 +118,13 @@ public function testOptionRequiredAndDefault(): void 'Float' => 0, 'String' => 'false', 'Array' => ['false'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionOptional(): void { - $option = 'opt-optional'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-optional'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => null, 'Bool' => false, @@ -133,9 +132,9 @@ public function testOptionOptional(): void 'Float' => 0, 'String' => '', 'Array' => [], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5'"); isSame([ 'Default' => '123.5', 'Bool' => true, @@ -143,9 +142,9 @@ public function testOptionOptional(): void 'Float' => 123.5, 'String' => '123.5', 'Array' => ['123.5'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='0'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='0'"); isSame([ 'Default' => '0', 'Bool' => false, @@ -153,9 +152,9 @@ public function testOptionOptional(): void 'Float' => 0, 'String' => '0', 'Array' => ['0'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false'"); isSame([ 'Default' => 'false', 'Bool' => false, @@ -163,13 +162,13 @@ public function testOptionOptional(): void 'Float' => 0, 'String' => 'false', 'Array' => ['false'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionOptionalDefault(): void { - $option = 'opt-optional-default'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-optional-default'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => '456.8', 'Bool' => true, @@ -177,9 +176,9 @@ public function testOptionOptionalDefault(): void 'Float' => 456.8, 'String' => '456.8', 'Array' => ['456.8'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5'"); isSame([ 'Default' => '123.5', 'Bool' => true, @@ -187,9 +186,9 @@ public function testOptionOptionalDefault(): void 'Float' => 123.5, 'String' => '123.5', 'Array' => ['123.5'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='0'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='0'"); isSame([ 'Default' => '0', 'Bool' => false, @@ -197,9 +196,9 @@ public function testOptionOptionalDefault(): void 'Float' => 0, 'String' => '0', 'Array' => ['0'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false'"); isSame([ 'Default' => 'false', 'Bool' => false, @@ -207,13 +206,13 @@ public function testOptionOptionalDefault(): void 'Float' => 0, 'String' => 'false', 'Array' => ['false'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionArrayOptional(): void { - $option = 'opt-array-optional'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-array-optional'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => [], 'Bool' => false, @@ -221,9 +220,9 @@ public function testOptionArrayOptional(): void 'Float' => 0, 'String' => '', 'Array' => [], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'"); isSame([ 'Default' => ['123.5', '789.1'], 'Bool' => true, @@ -231,9 +230,9 @@ public function testOptionArrayOptional(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['123.5', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'"); isSame([ 'Default' => ['0', '789.1'], 'Bool' => true, @@ -241,9 +240,9 @@ public function testOptionArrayOptional(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['0', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'"); isSame([ 'Default' => ['false', '789.1'], 'Bool' => true, @@ -251,13 +250,13 @@ public function testOptionArrayOptional(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['false', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionArrayRequired(): void { - $option = 'opt-array-req'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-array-req'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => [], 'Bool' => false, @@ -265,9 +264,9 @@ public function testOptionArrayRequired(): void 'Float' => 0, 'String' => '', 'Array' => [], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'"); isSame([ 'Default' => ['123.5', '789.1'], 'Bool' => true, @@ -275,9 +274,9 @@ public function testOptionArrayRequired(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['123.5', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'"); isSame([ 'Default' => ['0', '789.1'], 'Bool' => true, @@ -285,9 +284,9 @@ public function testOptionArrayRequired(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['0', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'"); isSame([ 'Default' => ['false', '789.1'], 'Bool' => true, @@ -295,13 +294,13 @@ public function testOptionArrayRequired(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['false', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } public function testOptionArrayRequiredDefault(): void { - $option = 'opt-array-req-default'; - $output = json(Helper::executeReal('test:cli-options')[1])->getArrayCopy(); + $option = 'opt-array-req-default'; + $cmdResult = Helper::executeReal('test:cli-options'); isSame([ 'Default' => ['456.8'], 'Bool' => true, @@ -309,9 +308,9 @@ public function testOptionArrayRequiredDefault(): void 'Float' => 456.8, 'String' => '456.8', 'Array' => ['456.8'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='123.5' --{$option}='789.1'"); isSame([ 'Default' => ['123.5', '789.1'], 'Bool' => true, @@ -319,9 +318,9 @@ public function testOptionArrayRequiredDefault(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['123.5', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}=0 --{$option}='789.1'"); isSame([ 'Default' => ['0', '789.1'], 'Bool' => true, @@ -329,9 +328,9 @@ public function testOptionArrayRequiredDefault(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['0', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); - $output = json(Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'")[1])->getArrayCopy(); + $cmdResult = Helper::executeReal("test:cli-options --{$option}='false' --{$option}='789.1'"); isSame([ 'Default' => ['false', '789.1'], 'Bool' => true, @@ -339,6 +338,6 @@ public function testOptionArrayRequiredDefault(): void 'Float' => 789.1, 'String' => '789.1', 'Array' => ['false', '789.1'], - ], $output[$option]); + ], $cmdResult->stdJson()->get($option)); } } diff --git a/tests/CliOutputLogstashTest.php b/tests/CliOutputLogstashTest.php new file mode 100644 index 0000000..7095a48 --- /dev/null +++ b/tests/CliOutputLogstashTest.php @@ -0,0 +1,542 @@ + 'logstash']); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $lines = Helper::prepareLogstash($cmdResult->std); + $lineAsArray = $lines[0]->getArrayCopy(); + $lineStruture = self::replaceValues($lineAsArray); + + isCount(9, $lines); + isSame([ + '@timestamp' => 'string', + '@version' => 'integer', + 'host' => 'string', + 'message' => 'string', + 'type' => 'string', + 'channel' => 'string', + 'level' => 'string', + 'monolog_level' => 'integer', + 'context' => [ + 'trace' => ['id' => 'string'], + 'profile' => [ + 'memory_usage_real' => 'integer', + 'memory_usage' => 'integer', + 'memory_usage_diff' => 'integer', + 'memory_pick_real' => 'integer', + 'memory_pick' => 'integer', + 'time_total_ms' => 'double', + 'time_diff_ms' => 'double', + ], + ], + ], $lineStruture); + } + + public function testFormatOfMessageVerboseFisrt(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-vvv' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $lines = Helper::prepareLogstash($cmdResult->std); + $lineAsArray = $lines[0]->getArrayCopy(); + $lineStruture = self::replaceValues($lineAsArray); + + isCount(19, $lines); + isSame([ + '@timestamp' => 'string', + '@version' => 'integer', + 'host' => 'string', + 'message' => 'string', + 'type' => 'string', + 'channel' => 'string', + 'level' => 'string', + 'monolog_level' => 'integer', + 'context' => [ + 'trace' => ['id' => 'string'], + 'profile' => [ + 'memory_usage_real' => 'integer', + 'memory_usage' => 'integer', + 'memory_usage_diff' => 'integer', + 'memory_pick_real' => 'integer', + 'memory_pick' => 'integer', + 'time_total_ms' => 'double', + 'time_diff_ms' => 'double', + ], + 'service' => [ + 'name' => 'string', + 'version' => 'string', + 'type' => 'string', + ], + 'process' => [ + 'pid' => 'integer', + 'executable' => 'string', + 'args_count' => ['string', 'string', 'string', 'string', 'string'], + 'command_line' => 'string', + 'process_command' => 'string', + 'args' => [ + 'command' => 'string', + 'exception' => 'NULL', + 'type-of-vars' => 'boolean', + 'no-progress' => 'boolean', + 'mute-errors' => 'boolean', + 'stdout-only' => 'boolean', + 'non-zero-on-error' => 'boolean', + 'timestamp' => 'boolean', + 'profile' => 'boolean', + 'output-mode' => 'string', + 'cron' => 'boolean', + 'help' => 'boolean', + 'quiet' => 'boolean', + 'verbose' => 'boolean', + 'version' => 'boolean', + 'ansi' => 'boolean', + 'no-interaction' => 'boolean', + ], + 'working_directory' => 'string', + ], + ], + ], $lineStruture); + } + + public function testFormatOfMessageVerboseLast(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-vvv' => null]); + $lineAsArray = Helper::prepareLogstash($cmdResult->std)[18]->getArrayCopy(); + $lineStruture = self::replaceValues($lineAsArray); + + isSame([ + '@timestamp' => 'string', + '@version' => 'integer', + 'host' => 'string', + 'message' => 'string', + 'type' => 'string', + 'channel' => 'string', + 'level' => 'string', + 'monolog_level' => 'integer', + 'context' => [ + 'trace' => ['id' => 'string'], + 'profile' => [ + 'memory_usage_real' => 'integer', + 'memory_usage' => 'integer', + 'memory_usage_diff' => 'integer', + 'memory_pick_real' => 'integer', + 'memory_pick' => 'integer', + 'time_total_ms' => 'double', + 'time_diff_ms' => 'double', + ], + 'process' => ['exit_code' => 'integer'], + ], + ], $lineStruture); + } + + public function testFormatOfMessageException(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'exception' => 'Some message']); + + $lineAsArray = Helper::prepareLogstash($cmdResult->std)[9]->getArrayCopy(); + $lineStruture = self::replaceValues($lineAsArray); + + isSame([ + '@timestamp' => 'string', + '@version' => 'integer', + 'host' => 'string', + 'message' => 'string', + 'type' => 'string', + 'channel' => 'string', + 'level' => 'string', + 'monolog_level' => 'integer', + 'context' => [ + 'trace' => ['id' => 'string'], + 'profile' => [ + 'memory_usage_real' => 'integer', + 'memory_usage' => 'integer', + 'memory_usage_diff' => 'integer', + 'memory_pick_real' => 'integer', + 'memory_pick' => 'integer', + 'time_total_ms' => 'double', + 'time_diff_ms' => 'double', + ], + 'error' => [ + 'type' => 'string', + 'code' => 'integer', + 'message' => 'string', + 'file' => 'string', + 'stack_trace' => 'string', + 'previous' => 'NULL', + ], + ], + ], $lineStruture); + } + + public function testNormal(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash']); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(9, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + } + + public function testInfo(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-v' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(15, $stdOutput); + Helper::assertLogstash(['INFO', 'Command Start: test:output'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[2]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[3]); + Helper::assertLogstash(['INFO', 'Info1 -v'], $stdOutput[4]); + Helper::assertLogstash(['INFO', 'Info2 -v'], $stdOutput[5]); + Helper::assertLogstash(['INFO', 'Verbose1 -vv'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[7]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[8]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[9]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[10]); + Helper::assertLogstash(['INFO', 'Quiet -q'], $stdOutput[11]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[12]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[13]); + Helper::assertLogstash(['INFO', 'Command Finish: ExitCode=0'], $stdOutput[14]); + + isSame( + Helper::executeReal('test:output', ['v' => null])->std, + Helper::executeReal('test:output', ['verbose' => null])->std, + ); + } + + public function testVerbose(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-vv' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(15, $stdOutput); + Helper::assertLogstash(['INFO', 'Command Start: test:output'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[2]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[3]); + Helper::assertLogstash(['INFO', 'Info1 -v'], $stdOutput[4]); + Helper::assertLogstash(['INFO', 'Info2 -v'], $stdOutput[5]); + Helper::assertLogstash(['INFO', 'Verbose1 -vv'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[7]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[8]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[9]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[10]); + Helper::assertLogstash(['INFO', 'Quiet -q'], $stdOutput[11]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[12]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[13]); + Helper::assertLogstash(['INFO', 'Command Finish: ExitCode=0'], $stdOutput[14]); + } + + public function testDebug(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-vvv' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(19, $stdOutput); + Helper::assertLogstash(['INFO', 'Command Start: test:output'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[2]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[3]); + Helper::assertLogstash(['INFO', 'Info1 -v'], $stdOutput[4]); + Helper::assertLogstash(['INFO', 'Info2 -v'], $stdOutput[5]); + Helper::assertLogstash(['INFO', 'Verbose1 -vv'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[7]); + Helper::assertLogstash(['DEBUG', 'Debug1 -vvv'], $stdOutput[8]); + Helper::assertLogstash(['DEBUG', 'Message #1 -vvv'], $stdOutput[9]); + Helper::assertLogstash(['DEBUG', 'Message #2 -vvv'], $stdOutput[10]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[11]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[12]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[13]); + Helper::assertLogstash(['DEBUG', 'Message with context'], $stdOutput[14]); + Helper::assertLogstash(['INFO', 'Quiet -q'], $stdOutput[15]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[16]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[17]); + Helper::assertLogstash(['INFO', 'Command Finish: ExitCode=0'], $stdOutput[18]); + } + + public function testQuite(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', '-q' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(15, $stdOutput); + Helper::assertLogstash(['INFO', 'Command Start: test:output'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[2]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[3]); + Helper::assertLogstash(['INFO', 'Info1 -v'], $stdOutput[4]); + Helper::assertLogstash(['INFO', 'Info2 -v'], $stdOutput[5]); + Helper::assertLogstash(['INFO', 'Verbose1 -vv'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[7]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[8]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[9]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[10]); + Helper::assertLogstash(['INFO', 'Quiet -q'], $stdOutput[11]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[12]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[13]); + Helper::assertLogstash(['INFO', 'Command Finish: ExitCode=0'], $stdOutput[14]); + } + + public function testProfile(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'profile' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(9, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + } + + public function testStdoutOnly(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'stdout-only' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(9, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + } + + public function testNonZeroOnError(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'non-zero-on-error' => null]); + isSame(1, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(9, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + } + + public function testException(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'exception' => 'Some message']); + isSame(1, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(10, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + Helper::assertLogstash(['CRITICAL', 'Command Exception: Some message'], $stdOutput[9]); + } + + public function testTimestamp(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'timestamp' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(9, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + } + + public function testTypeOfVars(): void + { + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'logstash', 'type-of-vars' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(8, $stdOutput); + Helper::assertLogstash(['NOTICE', ' '], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', 'true'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', 'false'], $stdOutput[3]); + Helper::assertLogstash(['NOTICE', 'null'], $stdOutput[4]); + Helper::assertLogstash(['NOTICE', '1'], $stdOutput[5]); + Helper::assertLogstash(['NOTICE', '1'], $stdOutput[6]); + Helper::assertLogstash(['NOTICE', '-0.001'], $stdOutput[7]); + } + + public function testMuteErrors(): void + { + $cmdResult = Helper::executeReal( + 'test:output', + ['output-mode' => 'logstash', 'exception' => 'Some message', 'mute-errors' => null], + ); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(10, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + Helper::assertLogstash(['CRITICAL', 'Command Exception: Some message'], $stdOutput[9]); + } + + public function testMuteErrorsAndNonZeroOnError(): void + { + $cmdResult = Helper::executeReal('test:output', [ + 'output-mode' => 'logstash', + 'exception' => 'Some message', + 'mute-errors' => null, + 'non-zero-on-error' => null, + ]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(10, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Normal 1'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Normal 2'], $stdOutput[1]); + Helper::assertLogstash(['ERROR', 'Message'], $stdOutput[2]); + Helper::assertLogstash(['WARNING', 'Verbose2 -vv'], $stdOutput[3]); + Helper::assertLogstash(['ERROR', 'Error (e)'], $stdOutput[4]); + Helper::assertLogstash(['ERROR', 'Error (error)'], $stdOutput[5]); + Helper::assertLogstash(['CRITICAL', 'Error (exception)'], $stdOutput[6]); + Helper::assertLogstash(['WARNING', 'Legacy'], $stdOutput[7]); + Helper::assertLogstash(['WARNING', ' Message'], $stdOutput[8]); + Helper::assertLogstash(['CRITICAL', 'Command Exception: Some message'], $stdOutput[9]); + } + + public function testCronAlias(): void + { + $cmdResult = Helper::executeReal('test:output', [ + 'output-mode' => 'logstash', + 'exception' => 'Some message', + 'cron' => null, + ]); + + isSame(1, $cmdResult->code); + isSame('', $cmdResult->err); + + // `--cron` has higher proirity than `--output-mode=logstash` + isContain('] Normal 1', $cmdResult->std); + } + + public function testTraceId(): void + { + $cmdResult = Helper::executeReal( + 'test:output', + ['output-mode' => 'logstash', 'exception' => 'Some message', '-vvv' => null], + ); + isSame(1, $cmdResult->code); + isSame('', $cmdResult->err); + + $stdOutput = Helper::prepareLogstash($cmdResult->std); + isCount(20, $stdOutput); + + // Trace id is UUID v4 + $pattern = '/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/'; + isSame(1, \preg_match($pattern, $stdOutput[0]->find('context.trace.id'))); + + $traces = []; + + foreach ($stdOutput as $log) { + $traces[] = $log->find('context.trace.id'); + } + + isCount(1, \array_unique($traces)); + + $cmdResult2 = Helper::executeReal('test:output', ['output-mode' => 'logstash']); + $stdOutput2 = Helper::prepareLogstash($cmdResult2->std); + isNotSame($stdOutput[0]->find('context.trace.id'), $stdOutput2[0]->find('context.trace.id')); + } + + /** + * Recursively replaces the values in the given array with their corresponding data types. + */ + private static function replaceValues(array &$array): array + { + foreach ($array as &$value) { + if (\is_array($value)) { + self::replaceValues($value); + } else { + $value = \gettype($value); + } + } + + return $array; + } +} diff --git a/tests/CliOutputTest.php b/tests/CliOutputTest.php deleted file mode 100644 index b8c47c1..0000000 --- a/tests/CliOutputTest.php +++ /dev/null @@ -1,309 +0,0 @@ - null]); - isSame(\implode(\PHP_EOL, [ - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - ]), $errOut); - isSame(0, $exitCode); - - isSame(\implode("\n", [ - 'Normal 1', - 'Normal 2', - 'Info1 -v', - 'Info: Info2 -v', - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - - isSame( - Helper::executeReal('test:output', ['v' => null])[1], - Helper::executeReal('test:output', ['verbose' => null])[1], - ); - } - - public function testVerbose(): void - { - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['-vv' => null]); - isSame(\implode(\PHP_EOL, [ - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - ]), $errOut); - isSame(0, $exitCode); - - isSame(\implode("\n", [ - 'Normal 1', - 'Normal 2', - - 'Info1 -v', - 'Info: Info2 -v', - - 'Verbose1 -vv', - 'Warning: Verbose2 -vv', - - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - } - - public function testDebug(): void - { - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['-vvv' => null]); - isSame(\implode(\PHP_EOL, [ - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - ]), $errOut); - isSame(0, $exitCode); - - isContain('Debug: Working Directory is ', $stdOut); - isContain(\implode("\n", [ - 'Normal 1', - 'Normal 2', - - 'Info1 -v', - 'Info: Info2 -v', - - 'Verbose1 -vv', - 'Warning: Verbose2 -vv', - - 'Debug1 -vvv', - 'Debug: Message #1 -vvv', - 'Debug: Message #2 -vvv', - - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - 'Debug: Exit Code is "0"', - ]), $stdOut); - } - - public function testQuiet(): void - { - isContain('Quiet -q', Helper::executeReal('test:output', ['q' => null])[1]); - isContain('Quiet -q', Helper::executeReal('test:output', ['quiet' => null])[1]); - } - - public function testProfile(): void - { - $output = Helper::executeReal('test:output', ['profile' => null])[1]; - - isContain('B] Normal 1', $output); - isContain('B] Normal 2', $output); - isContain('B] Quiet -q', $output); - isContain('B] Memory Usage/Peak:', $output); - isContain('Execution Time:', $output); - } - - public function testStdoutOnly(): void - { - // Redirect common message - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['stdout-only' => null]); - - isSame('', $errOut); - isSame(0, $exitCode); - - isSame(\implode("\n", [ - 'Normal 1', - 'Normal 2', - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - - // Redirect exception messsage - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', [ - 'stdout-only' => null, - 'exception' => 'Some message', - ]); - isSame('', $errOut); - isSame(1, $exitCode); - isContain(' Some message ', $stdOut); - isContain(\implode("\n", [ - 'Normal 1', - 'Normal 2', - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - - // No redirect exception messsage - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', [ - 'exception' => 'Some message', - ]); - isContain('Error: Message', $errOut); - isContain(' Some message ', $errOut); - isSame(1, $exitCode); - - isContain(\implode("\n", [ - 'Normal 1', - 'Normal 2', - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - } - - public function testStrict(): void - { - // Redirect common message - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['non-zero-on-error' => null]); - isSame(\implode(\PHP_EOL, [ - 'Error: Message', - 'Error (e)', - 'Error: Error (error)', - 'Muted Exception: Error (exception)', - ]), $errOut); - isSame(1, $exitCode); - - isSame(\implode("\n", [ - 'Normal 1', - 'Normal 2', - 'Quiet -q', - 'Legacy Output: Legacy', - 'Legacy Output: Message', - ]), $stdOut); - } - - public function testTimestamp(): void - { - // Redirect common message - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['timestamp' => null]); - isContain('] Error: Message', $errOut); - isSame(0, $exitCode); - - isContain('] Normal 1', $stdOut); - isContain('] Normal 2', $stdOut); - isContain('] Quiet -q', $stdOut); - } - - public function testTypeOfVars(): void - { - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:output', ['type-of-vars' => null]); - isSame(0, $exitCode); - isSame('', $errOut); - isSame(\implode("\n", [ - '0', - 'true', - 'false', - 'null', - '1', - '1', - '-0.001', - ]), $stdOut); - } - - public function testMuteErrors(): void - { - $exceptionMessage = 'Some message ' . Str::random(); - - [$exitCode, , $errOut] = Helper::executeReal('test:output', ['exception' => $exceptionMessage]); - isSame(1, $exitCode); - isContain($exceptionMessage, $errOut); - - [$exitCode, , $errOut] = Helper::executeReal( - 'test:output', - ['exception' => $exceptionMessage, 'mute-errors' => null], - ); - isSame(0, $exitCode); - isContain($exceptionMessage, $errOut); - - [$exitCode, , $errOut] = Helper::executeReal( - 'test:output', - ['exception' => $exceptionMessage, 'mute-errors' => null, 'non-zero-on-error' => null], - ); - isSame(0, $exitCode); - isContain($exceptionMessage, $errOut); - } - - public function testCronMode(): void - { - [$exitCode, $stdOut, $errOut] = Helper::executeReal( - 'test:output', - ['cron' => null, 'exception' => 'Custom runtime error'], - ); - - isSame(1, $exitCode); - isSame('', $errOut); - - isContain('] Normal 1', $stdOut); - isContain('] Normal 2', $stdOut); - isContain('] Error: Message', $stdOut); - isContain('] Info1 -v', $stdOut); - isContain('] Info: Info2 -v', $stdOut); - isContain('] Verbose1 -vv', $stdOut); - isContain('] Warning: Verbose2 -vv', $stdOut); - isContain('] Error (e)', $stdOut); - isContain('] Error: Error (error)', $stdOut); - isContain('] Muted Exception: Error (exception)', $stdOut); - isContain('] Quiet -q', $stdOut); - isContain('] Legacy Output: Legacy', $stdOut); - isContain('] Legacy Output: Message', $stdOut); - isContain('] Memory Usage/Peak:', $stdOut); - - isContain('[JBZoo\Cli\Exception]', $stdOut); - isContain('Custom runtime error', $stdOut); - isContain('Exception trace:', $stdOut); - - isNotContain('Debug1 -vvv', $stdOut); - isNotContain('Message #1 -vvv', $stdOut); - isNotContain('Message #2 -vvv', $stdOut); - } -} diff --git a/tests/CliOutputTextTest.php b/tests/CliOutputTextTest.php new file mode 100644 index 0000000..28e60e5 --- /dev/null +++ b/tests/CliOutputTextTest.php @@ -0,0 +1,388 @@ +code); + isSame( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + + isSame( + \implode(\PHP_EOL, [ + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + ]), + $cmdResult->err, + ); + } + + public function testInfo(): void + { + $cmdResult = Helper::executeReal('test:output', ['v' => null]); + isSame( + \implode(\PHP_EOL, [ + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + ]), + $cmdResult->err, + ); + isSame(0, $cmdResult->code); + + isSame( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Info1 -v', + 'Info: Info2 -v', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + + isSame( + Helper::executeReal('test:output', ['v' => null])->std, + Helper::executeReal('test:output', ['verbose' => null])->std, + ); + } + + public function testVerbose(): void + { + $cmdResult = Helper::executeReal('test:output', ['-vv' => null]); + isSame( + \implode(\PHP_EOL, [ + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + ]), + $cmdResult->err, + ); + isSame(0, $cmdResult->code); + + isSame( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + + 'Info1 -v', + 'Info: Info2 -v', + + 'Verbose1 -vv', + 'Warning: Verbose2 -vv', + + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + } + + public function testDebug(): void + { + $cmdResult = Helper::executeReal('test:output', ['-vvv' => null]); + isSame( + \implode(\PHP_EOL, [ + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + ]), + $cmdResult->err, + ); + isSame(0, $cmdResult->code); + + isContain('Debug: Working Directory is ', $cmdResult->std); + isContain( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + + 'Info1 -v', + 'Info: Info2 -v', + + 'Verbose1 -vv', + 'Warning: Verbose2 -vv', + + 'Debug1 -vvv', + 'Debug: Message #1 -vvv', + 'Debug: Message #2 -vvv', + 'Debug: Message with context {"foo":"bar"}', + + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + + isContain('Debug: Memory Usage/Peak:', $cmdResult->std); + isContain('Debug: Exit Code is "0"', $cmdResult->std); + } + + public function testQuiet(): void + { + isContain('Quiet -q', Helper::executeReal('test:output', ['q' => null])->std); + isContain('Quiet -q', Helper::executeReal('test:output', ['quiet' => null])->std); + } + + public function testProfile(): void + { + $cmdResult = Helper::executeReal('test:output', ['profile' => null]); + + isContain('B] Normal 1', $cmdResult->std); + isContain('B] Normal 2', $cmdResult->std); + isContain('B] Quiet -q', $cmdResult->std); + isContain('B] Memory Usage/Peak:', $cmdResult->std); + isContain('Execution Time:', $cmdResult->std); + + $firstLine = \explode("\n", $cmdResult->std)[0]; + $lineParts = \explode('] ', $firstLine); + + isTrue(Helper::validateProfilerFormat($lineParts[0] . ']'), $firstLine); + } + + public function testStdoutOnly(): void + { + // Redirect common message + $cmdResult = Helper::executeReal('test:output', ['stdout-only' => null]); + + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + + isSame( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + + // Redirect exception messsage + $cmdResult = Helper::executeReal('test:output', ['stdout-only' => null, 'exception' => 'Some message']); + isEmpty($cmdResult->err, $cmdResult->err); + isSame(1, $cmdResult->code); + isContain(' Some message ', $cmdResult->std); + isContain( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + + // No redirect exception messsage + $cmdResult = Helper::executeReal('test:output', ['exception' => 'Some message']); + isContain('Error: Message', $cmdResult->err); + isContain(' Some message ', $cmdResult->err); + isSame(1, $cmdResult->code); + + isContain( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + } + + public function testStrict(): void + { + // Redirect common message + $cmdResult = Helper::executeReal('test:output', ['non-zero-on-error' => null]); + isSame( + \implode(\PHP_EOL, [ + 'Error: Message', + 'Error (e)', + 'Error: Error (error)', + 'Muted Exception: Error (exception)', + ]), + $cmdResult->err, + ); + isSame(1, $cmdResult->code); + + isSame( + \implode("\n", [ + 'Normal 1', + 'Normal 2', + 'Quiet -q', + 'Legacy Output: Legacy', + 'Legacy Output: Message', + ]), + $cmdResult->std, + ); + } + + public function testTimestamp(): void + { + // Redirect common message + $cmdResult = Helper::executeReal('test:output', ['timestamp' => null]); + isContain('] Error: Message', $cmdResult->err); + isSame(0, $cmdResult->code); + + isContain('] Normal 1', $cmdResult->std); + isContain('] Normal 2', $cmdResult->std); + isContain('] Quiet -q', $cmdResult->std); + + $firstLine = \explode("\n", $cmdResult->std)[0]; + $lineParts = \explode(' ', $firstLine); + + isTrue(Helper::validateDateFormat($lineParts[0]), $firstLine); + } + + public function testTypeOfVars(): void + { + $cmdResult = Helper::executeReal('test:output', ['type-of-vars' => null]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + isSame( + \implode("\n", [ + '0', + 'true', + 'false', + 'null', + '1', + '1', + '-0.001', + ]), + $cmdResult->std, + ); + } + + public function testMuteErrors(): void + { + $exceptionMessage = 'Some message ' . Str::random(); + + $cmdResult = Helper::executeReal('test:output', ['exception' => $exceptionMessage]); + isSame(1, $cmdResult->code); + isContain($exceptionMessage, $cmdResult->err); + + $cmdResult = Helper::executeReal('test:output', ['exception' => $exceptionMessage, 'mute-errors' => null]); + isSame(0, $cmdResult->code); + isContain($exceptionMessage, $cmdResult->err); + + $cmdResult = Helper::executeReal( + 'test:output', + ['exception' => $exceptionMessage, 'mute-errors' => null, 'non-zero-on-error' => null], + ); + isSame(0, $cmdResult->code); + isContain($exceptionMessage, $cmdResult->err); + } + + public function testException(): void + { + $errMessage = 'Something went wrong'; + + $cmdResult = Helper::executeReal('test:output', ['exception' => $errMessage]); + + isSame(1, $cmdResult->code); + + isNotContain('Muted Exception: Something went wrong', $cmdResult->std); + isNotContain('Muted Exception: Something went wrong', $cmdResult->err); + isNotContain('Something went wrong', $cmdResult->std); + isContain('Something went wrong', $cmdResult->err); + + isContain('In TestOutput.php line', $cmdResult->err); + } + + public function testCronMode(): void + { + $cmdResult = Helper::executeReal('test:output', ['cron' => null, 'exception' => 'Custom runtime error']); + + $message = (string)$cmdResult; + + isEmpty($cmdResult->err, $message); + isSame(1, $cmdResult->code, $message); + isSame('', $cmdResult->err, $message); + + isContain('] Normal 1', $cmdResult->std, false, $message); + isContain('] Normal 2', $cmdResult->std, false, $message); + isContain('] Error: Message', $cmdResult->std, false, $message); + isContain('] Info1 -v', $cmdResult->std, false, $message); + isContain('] Info: Info2 -v', $cmdResult->std, false, $message); + isContain('] Verbose1 -vv', $cmdResult->std, false, $message); + isContain('] Warning: Verbose2 -vv', $cmdResult->std, false, $message); + isContain('] Error (e)', $cmdResult->std, false, $message); + isContain('] Error: Error (error)', $cmdResult->std, false, $message); + isContain('] Muted Exception: Error (exception)', $cmdResult->std, false, $message); + isContain('] Quiet -q', $cmdResult->std, false, $message); + isContain('] Legacy Output: Legacy', $cmdResult->std, false, $message); + isContain('] Legacy Output: Message', $cmdResult->std, false, $message); + isContain('] Memory Usage/Peak:', $cmdResult->std, false, $message); + + isContain('[JBZoo\Cli\Exception]', $cmdResult->std, false, $message); + isContain('Custom runtime error', $cmdResult->std, false, $message); + isContain('Exception trace:', $cmdResult->std, false, $message); + + isNotContain('Debug1 -vvv', $cmdResult->std, false, $message); + isNotContain('Message #1 -vvv', $cmdResult->std, false, $message); + isNotContain('Message #2 -vvv', $cmdResult->std, false, $message); + } + + public function testCronModeAlias(): void + { + $errMessage = 'Custom runtime error'; + + $cmdResultAlias = Helper::executeReal('test:output', ['cron' => null, 'exception' => $errMessage]); + $cmdResult = Helper::executeReal('test:output', ['output-mode' => 'cron', 'exception' => $errMessage]); + + isSame(1, $cmdResultAlias->code); + isSame(1, $cmdResult->code); + + isSame($cmdResultAlias->err, $cmdResult->err); + isSame(\str_word_count($cmdResultAlias->std), \str_word_count($cmdResult->std)); + isSame(\count(\explode("\n", $cmdResultAlias->std)), \count(\explode("\n", $cmdResult->std))); + } +} diff --git a/tests/CliProgressLogstashTest.php b/tests/CliProgressLogstashTest.php new file mode 100644 index 0000000..a1f5b90 --- /dev/null +++ b/tests/CliProgressLogstashTest.php @@ -0,0 +1,249 @@ +exec('minimal'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Number of steps: 2.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=2): Empty Output'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=2): Empty Output'], $stdOutput[2]); + } + + public function testCustomMessages(): void + { + $stdOutput = $this->exec('one-message'); + isCount(4, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "one-message". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): Empty Output'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): 1, 1, 1'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): Empty Output'], $stdOutput[3]); + + $stdOutput = $this->exec('simple-message-all'); + isCount(4, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "simple-message-all". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 0, 0, 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): 1, 1, 1'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): 2, 2, 2'], $stdOutput[3]); + + $stdOutput = $this->exec('array-int'); + isCount(4, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "array-int". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 4, 0, 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): 5, 1, 1'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): 6, 2, 2'], $stdOutput[3]); + + $stdOutput = $this->exec('array-string'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "array-string". Number of steps: 2.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=2): qwerty, 0, 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=2): asdfgh, 1, 1'], $stdOutput[2]); + + $stdOutput = $this->exec('array-assoc'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "array-assoc". Number of steps: 2.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Key=0/Step=1/Max=2): value_1, key_1, 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Key=1/Step=2/Max=2): value_2, key_2, 1'], $stdOutput[2]); + + $stdOutput = $this->exec('data'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "data". Number of steps: 2.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Key=0/Step=1/Max=2): value_1, key_1, 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Key=1/Step=2/Max=2): value_2, key_2, 1'], $stdOutput[2]); + + $stdOutput = $this->exec('output-as-array'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "output-as-array". Number of steps: 2.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Key=0/Step=1/Max=2): value_1; key_1; 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Key=1/Step=2/Max=2): value_2; key_2; 1'], $stdOutput[2]); + } + + public function testBreakWithLegactyReturnMessage(): void + { + $stdOutput = $this->exec('break'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "break". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): Progress aborted.'], $stdOutput[2]); + } + + public function testBreakWithException(): void + { + $stdOutput = $this->exec('break-exception'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "break-exception". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 0'], $stdOutput[1]); + Helper::assertLogstash( + [ + 'NOTICE', + '(Step=2/Max=3): Progress aborted. Something went wrong with $listValue=1', + ], + $stdOutput[2], + ); + } + + public function testNoItems(): void + { + $stdOutput = $this->exec('no-items-int'); + isCount(1, $stdOutput); + Helper::assertLogstash(['NOTICE', 'no-items-int. Number of items is 0 or less.'], $stdOutput[0]); + + $stdOutput = $this->exec('no-items-array'); + isCount(1, $stdOutput); + Helper::assertLogstash(['NOTICE', 'no-items-array. Number of items is 0 or less.'], $stdOutput[0]); + + $stdOutput = $this->exec('no-items-data'); + isCount(1, $stdOutput); + Helper::assertLogstash(['NOTICE', 'no-items-data. Number of items is 0 or less.'], $stdOutput[0]); + } + + public function testException(): void + { + $stdOutput = $this->exec('exception', [], 1); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "exception". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): Empty Output'], $stdOutput[1]); + Helper::assertLogstash(['CRITICAL', 'Command Exception: Exception #1'], $stdOutput[2]); + isSame('Exception', $stdOutput[2]->find('context.error.type')); + isSame(0, $stdOutput[2]->find('context.error.code')); + isSame('Exception #1', $stdOutput[2]->find('context.error.message')); + isNotEmpty($stdOutput[2]->find('context.error.file')); + isNotEmpty($stdOutput[2]->find('context.error.stack_trace')); + } + + public function testExceptionList(): void + { + $stdOutput = $this->exec('exception-list', [], 1); + isCount(2, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "exception-list". Number of steps: 10.'], $stdOutput[0]); + Helper::assertLogstash(['CRITICAL', 'Command Exception: Exception #0'], $stdOutput[1]); + isSame('RuntimeException', $stdOutput[1]->find('context.error.type')); + isSame(0, $stdOutput[1]->find('context.error.code')); + isSame('Exception #0', $stdOutput[1]->find('context.error.message')); + isNotEmpty($stdOutput[1]->find('context.error.file')); + isNotEmpty($stdOutput[1]->find('context.error.stack_trace')); + } + + public function testExceptionBatch(): void + { + $stdOutput = $this->exec('exception', ['batch-exception' => null], 1); + isCount(5, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "exception". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): Empty Output'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): Exception: Exception #1'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): Empty Output'], $stdOutput[3]); + Helper::assertLogstash( + ['CRITICAL', 'Command Exception: BatchExceptions: (Step=2/Max=3): Exception #1'], + $stdOutput[4], + ); + } + + public function testExceptionListBatch(): void + { + $stdOutput = $this->exec('exception-list', ['batch-exception' => null], 1); + isCount(12, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "exception-list". Number of steps: 10.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=10): Exception: Exception #0'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=10): Empty Output'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=10): Empty Output'], $stdOutput[3]); + Helper::assertLogstash(['NOTICE', '(Step=4/Max=10): Exception: Exception #3'], $stdOutput[4]); + Helper::assertLogstash(['NOTICE', '(Step=5/Max=10): Empty Output'], $stdOutput[5]); + Helper::assertLogstash(['NOTICE', '(Step=6/Max=10): Empty Output'], $stdOutput[6]); + Helper::assertLogstash(['NOTICE', '(Step=7/Max=10): Exception: Exception #6'], $stdOutput[7]); + Helper::assertLogstash(['NOTICE', '(Step=8/Max=10): Empty Output'], $stdOutput[8]); + Helper::assertLogstash(['NOTICE', '(Step=9/Max=10): Empty Output'], $stdOutput[9]); + Helper::assertLogstash(['NOTICE', '(Step=10/Max=10): Exception: Exception #9'], $stdOutput[10]); + Helper::assertLogstash( + [ + 'CRITICAL', + 'Command Exception: BatchExceptions: ' . + '(Step=1/Max=10): Exception #0; ' . + '(Step=4/Max=10): Exception #3; ' . + '(Step=7/Max=10): Exception #6; ' . + '(Step=10/Max=10): Exception #9', + ], + $stdOutput[11], + ); + } + + public function testNested(): void + { + $stdOutput = $this->exec('nested', ['-b' => null, '-vv' => null]); + isCount(21, $stdOutput); + Helper::assertLogstash(['INFO', 'Command Start: test:progress'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', 'Working on "nested_parent". Number of steps: 3.'], $stdOutput[1]); + Helper::assertLogstash( + ['NOTICE', 'Working on "nested_child_0". Number of steps: 4. Level: 2.'], + $stdOutput[2], + ); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=4): out_child_0_0'], $stdOutput[3]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=4): out_child_0_1'], $stdOutput[4]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=4): out_child_0_2'], $stdOutput[5]); + Helper::assertLogstash(['NOTICE', '(Step=4/Max=4): out_child_0_3'], $stdOutput[6]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): out_parent_0'], $stdOutput[7]); + Helper::assertLogstash( + ['NOTICE', 'Working on "nested_child_1". Number of steps: 4. Level: 2.'], + $stdOutput[8], + ); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=4): out_child_1_0'], $stdOutput[9]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=4): out_child_1_1'], $stdOutput[10]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=4): out_child_1_2'], $stdOutput[11]); + Helper::assertLogstash(['NOTICE', '(Step=4/Max=4): out_child_1_3'], $stdOutput[12]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): out_parent_1'], $stdOutput[13]); + Helper::assertLogstash( + ['NOTICE', 'Working on "nested_child_2". Number of steps: 4. Level: 2.'], + $stdOutput[14], + ); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=4): out_child_2_0'], $stdOutput[15]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=4): out_child_2_1'], $stdOutput[16]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=4): out_child_2_2'], $stdOutput[17]); + Helper::assertLogstash(['NOTICE', '(Step=4/Max=4): out_child_2_3'], $stdOutput[18]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): out_parent_2'], $stdOutput[19]); + Helper::assertLogstash(['INFO', 'Command Finish: ExitCode=0'], $stdOutput[20]); + } + + public function testCatchMode(): void + { + $stdOutput = $this->exec('catch-mode'); + isCount(4, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "catch-mode". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): Regular return 0; _(); cli(); echo'], $stdOutput[1]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): Regular return 1; _(); cli(); echo'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=3/Max=3): Regular return 2; _(); cli(); echo'], $stdOutput[3]); + } + + /** + * @return JSON[] + */ + private function exec(string $testCase, array $addOptions = [], int $excpectedExitCode = 0): array + { + $options = \array_merge(['output-mode' => 'logstash', 'case' => $testCase], $addOptions); + $cmdResult = Helper::executeReal('test:progress', $options); + $message = \print_r($cmdResult, true); + + isSame($excpectedExitCode, $cmdResult->code, $message); + isSame('', $cmdResult->err, $message); + isNotEmpty($cmdResult->std, $message); + + return Helper::prepareLogstash($cmdResult->std); + } +} diff --git a/tests/CliProgressTest.php b/tests/CliProgressTest.php index f3957fd..1a25482 100644 --- a/tests/CliProgressTest.php +++ b/tests/CliProgressTest.php @@ -20,245 +20,317 @@ class CliProgressTest extends PHPUnit { public function testMinimal(): void { - $output = Helper::executeReal('test:progress', ['case' => 'minimal', 'sleep' => 1]); - isSame(0, $output[0]); - isSame('', $output[1]); - isNotContain('Progress of minimal', $output[2]); - isContain('0% (0 / 2) [>', $output[2]); - isContain('50% (1 / 2) [•', $output[2]); - isContain('100% (2 / 2) [•', $output[2]); - isContain('Last Step Message: n/a', $output[2]); - - $output = Helper::executeReal('test:progress', ['case' => 'minimal', 'stdout-only' => null, 'sleep' => 1]); - isSame(0, $output[0]); - isSame('', $output[2]); - isContain('0% (0 / 2) [>', $output[1]); - isContain('50% (1 / 2) [•', $output[1]); - isContain('100% (2 / 2) [•', $output[1]); - isContain('Last Step Message: n/a', $output[1]); + $cmdResult = Helper::executeReal('test:progress', ['case' => 'minimal', 'sleep' => 1]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->std); + isNotContain('Progress of minimal', $cmdResult->err); + isContain('0% (0 / 2) [>', $cmdResult->err); + isContain('50% (1 / 2) [•', $cmdResult->err); + isContain('100% (2 / 2) [•', $cmdResult->err); + isContain('Last Step Message: n/a', $cmdResult->err); + + $cmdResult = Helper::executeReal('test:progress', ['case' => 'minimal', 'stdout-only' => null, 'sleep' => 1]); + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + isContain('0% (0 / 2) [>', $cmdResult->std); + isContain('50% (1 / 2) [•', $cmdResult->std); + isContain('100% (2 / 2) [•', $cmdResult->std); + isContain('Last Step Message: n/a', $cmdResult->std); } public function testMinimalVirtual(): void { - $output = Helper::executeVirtaul('test:progress', ['case' => 'one-message', 'ansi' => null]); - isContain('Progress of one-message', $output); - isContain('Last Step Message: 1, 1, 1', $output); + $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'one-message', 'ansi' => null]); + isContain('Progress of one-message', $cmdResult->std); + isContain('Last Step Message: 1, 1, 1', $cmdResult->std); - $output = Helper::executeVirtaul('test:progress', ['case' => 'array-assoc']); - isContain('Progress of array-assoc', $output); - isContain('Last Step Message: value_2, key_2, 1', $output); + $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'array-assoc']); + isContain('Progress of array-assoc', $cmdResult->std); + isContain('Last Step Message: value_2, key_2, 1', $cmdResult->std); } public function testNoItems(): void { - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:progress', ['case' => 'no-items-int']); - isSame('', $errOut); - isSame(0, $exitCode); - isSame('no-items-int. Number of items is 0 or less', $stdOut); - - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:progress', ['case' => 'no-items-array']); - isSame('', $errOut); - isSame(0, $exitCode); - isSame('no-items-array. Number of items is 0 or less', $stdOut); - - [$exitCode, $stdOut, $errOut] = Helper::executeReal('test:progress', ['case' => 'no-items-data']); - isSame('', $errOut); - isSame(0, $exitCode); - isSame('no-items-data. Number of items is 0 or less', $stdOut); + $cmdResult = Helper::executeReal('test:progress', ['case' => 'no-items-int']); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame('no-items-int. Number of items is 0 or less.', $cmdResult->std); + + $cmdResult = Helper::executeReal('test:progress', ['case' => 'no-items-array']); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame('no-items-array. Number of items is 0 or less.', $cmdResult->std); + + $cmdResult = Helper::executeReal('test:progress', ['case' => 'no-items-data']); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame('no-items-data. Number of items is 0 or less.', $cmdResult->std); + } + + public function testProgressMessagesLegacy(): void + { + $cmdResult = $this->exec('no-messages'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "no-messages". Number of steps: 3.', + ' * (0): n/a', + ' * (1): n/a', + ' * (2): n/a', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('one-message'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "one-message". Number of steps: 3.', + ' * (0): n/a', + ' * (1): 1, 1, 1', + ' * (2): n/a', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('simple-message-all'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "simple-message-all". Number of steps: 3.', + ' * (0): 0, 0, 0', + ' * (1): 1, 1, 1', + ' * (2): 2, 2, 2', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('array-int'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "array-int". Number of steps: 3.', + ' * (0): 4, 0, 0', + ' * (1): 5, 1, 1', + ' * (2): 6, 2, 2', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('array-string'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "array-string". Number of steps: 2.', + ' * (0): qwerty, 0, 0', + ' * (1): asdfgh, 1, 1', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('array-assoc'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "array-assoc". Number of steps: 2.', + ' * (key_1/0): value_1, key_1, 0', + ' * (key_2/1): value_2, key_2, 1', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('data'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "data". Number of steps: 2.', + ' * (key_1/0): value_1, key_1, 0', + ' * (key_2/1): value_2, key_2, 1', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('output-as-array'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "output-as-array". Number of steps: 2.', + ' * (key_1/0): value_1; key_1; 0', + ' * (key_2/1): value_2; key_2; 1', + ]), + $cmdResult->std, + ); } - public function testProgressMessages(): void + public function testProgressBreakLegacyReturnMessage(): void { - [$exitCode, $stdOut, $errOut] = $this->exec('no-messages'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "no-messages". Number of steps: 3.', - ' * (0): n/a', - ' * (1): n/a', - ' * (2): n/a', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('one-message'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "one-message". Number of steps: 3.', - ' * (0): n/a', - ' * (1): 1, 1, 1', - ' * (2): n/a', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('simple-message-all'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "simple-message-all". Number of steps: 3.', - ' * (0): 0, 0, 0', - ' * (1): 1, 1, 1', - ' * (2): 2, 2, 2', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('array-int'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "array-int". Number of steps: 3.', - ' * (0): 4, 0, 0', - ' * (1): 5, 1, 1', - ' * (2): 6, 2, 2', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('array-string'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "array-string". Number of steps: 2.', - ' * (0): qwerty, 0, 0', - ' * (1): asdfgh, 1, 1', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('array-assoc'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "array-assoc". Number of steps: 2.', - ' * (key_1/0): value_1, key_1, 0', - ' * (key_2/1): value_2, key_2, 1', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('data'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "data". Number of steps: 2.', - ' * (key_1/0): value_1, key_1, 0', - ' * (key_2/1): value_2, key_2, 1', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('break'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "break". Number of steps: 3.', - ' * (0): 0', - ' * (1): Progress stopped', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('output-as-array'); - isSame('', $errOut); - isSame(0, $exitCode); - isSame(\implode("\n", [ - 'Working on "output-as-array". Number of steps: 2.', - ' * (key_1/0): value_1; key_1; 0', - ' * (key_2/1): value_2; key_2; 1', - ]), $stdOut); + $cmdResult = $this->exec('break'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "break". Number of steps: 3.', + ' * (0): 0', + ' * (1): Progress aborted.', + ]), + $cmdResult->std, + ); + } + + public function testProgressBreakWithException(): void + { + $cmdResult = $this->exec('break-exception'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "break-exception". Number of steps: 3.', + ' * (0): 0', + ' * (1): Progress aborted. Something went wrong with $listValue=1', + ]), + $cmdResult->std, + ); } public function testException(): void { - [$exitCode, $stdOut, $errOut] = $this->exec('exception'); - isSame(1, $exitCode); - isContain('Exception #1', $errOut); - isSame(\implode("\n", [ - 'Working on "exception". Number of steps: 3.', - ' * (0): n/a', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('exception-list'); - isSame(1, $exitCode); - isContain('Exception #0', $errOut); - isSame('Working on "exception-list". Number of steps: 10.', $stdOut); + $cmdResult = $this->exec('exception'); + isSame(1, $cmdResult->code); + isContain('Exception #1', $cmdResult->err); + isSame( + \implode("\n", [ + 'Working on "exception". Number of steps: 3.', + ' * (0): n/a', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('exception-list'); + isSame(1, $cmdResult->code); + isContain('Exception #0', $cmdResult->err); + isSame('Working on "exception-list". Number of steps: 10.', $cmdResult->std); } public function testBatchException(): void { - [$exitCode, $stdOut, $errOut] = $this->exec('exception', ['batch-exception' => null]); - isSame(1, $exitCode); - isContain('Error list:', $errOut); - isContain('* (1): Exception #1', $errOut); - isSame(\implode("\n", [ - 'Working on "exception". Number of steps: 3.', - ' * (0): n/a', - ' * (1): Error. Exception #1', - ' * (2): n/a', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('exception-list', ['batch-exception' => null]); - isSame(1, $exitCode); - isContain('Error list:', $errOut); - isContain('* (0): Exception #0', $errOut); - isContain('* (3): Exception #3', $errOut); - isContain('* (6): Exception #6', $errOut); - isContain('* (9): Exception #9', $errOut); - isSame(\implode("\n", [ - 'Working on "exception-list". Number of steps: 10.', - ' * (0): Error. Exception #0', - ' * (1): n/a', - ' * (2): n/a', - ' * (3): Error. Exception #3', - ' * (4): n/a', - ' * (5): n/a', - ' * (6): Error. Exception #6', - ' * (7): n/a', - ' * (8): n/a', - ' * (9): Error. Exception #9', - ]), $stdOut); - - [$exitCode, $stdOut, $errOut] = $this->exec('exception-list', ['-b' => null, '-vv' => null], false); - isSame(1, $exitCode); - isContain('[JBZoo\Cli\ProgressBars\Exception]', $errOut); - isContain('Error list:', $errOut); - isContain('* (0): Exception #0', $errOut); - isContain('* (3): Exception #3', $errOut); - isContain('* (6): Exception #6', $errOut); - isContain('* (9): Exception #9', $errOut); - isContain('Caught exceptions : 4', $errOut); - isContain('Last Step Message : Error. Exception #9', $errOut); - isContain('Exception trace:', $errOut); - isSame('', $stdOut); + $cmdResult = $this->exec('exception', ['batch-exception' => null]); + isSame(1, $cmdResult->code); + isContain('Error list:', $cmdResult->err); + isContain('* (1): Exception #1', $cmdResult->err); + isSame( + \implode("\n", [ + 'Working on "exception". Number of steps: 3.', + ' * (0): n/a', + ' * (1): Exception: Exception #1', + ' * (2): n/a', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('exception-list', ['batch-exception' => null]); + isSame(1, $cmdResult->code); + isContain('Error list:', $cmdResult->err); + isContain('* (0): Exception #0', $cmdResult->err); + isContain('* (3): Exception #3', $cmdResult->err); + isContain('* (6): Exception #6', $cmdResult->err); + isContain('* (9): Exception #9', $cmdResult->err); + isSame( + \implode("\n", [ + 'Working on "exception-list". Number of steps: 10.', + ' * (0): Exception: Exception #0', + ' * (1): n/a', + ' * (2): n/a', + ' * (3): Exception: Exception #3', + ' * (4): n/a', + ' * (5): n/a', + ' * (6): Exception: Exception #6', + ' * (7): n/a', + ' * (8): n/a', + ' * (9): Exception: Exception #9', + ]), + $cmdResult->std, + ); + + $cmdResult = $this->exec('exception-list', ['-b' => null, '-vv' => null], false); + isSame(1, $cmdResult->code); + isContain('[JBZoo\Cli\ProgressBars\Exception]', $cmdResult->err); + isContain('Error list:', $cmdResult->err); + isContain('* (0): Exception #0', $cmdResult->err); + isContain('* (3): Exception #3', $cmdResult->err); + isContain('* (6): Exception #6', $cmdResult->err); + isContain('* (9): Exception #9', $cmdResult->err); + isContain('Caught exceptions : 4', $cmdResult->err); + isContain('Last Step Message : Exception: Exception #9', $cmdResult->err); + isContain('Exception trace:', $cmdResult->err); + isEmpty($cmdResult->std, $cmdResult->std); } public function testNested(): void { - [$exitCode, $stdOut, $errOut] = $this->exec('nested', ['-b' => null, '-vv' => null]); - - isSame(0, $exitCode); - isSame('', $errOut); - isSame(\implode("\n", [ - 'Working on "nested_parent". Number of steps: 3.', - 'Working on "nested_child_0". Number of steps: 4.', - ' * (0): out_child_0_0', - ' * (1): out_child_0_1', - ' * (2): out_child_0_2', - ' * (3): out_child_0_3', - '', - ' * (0): out_parent_0', - 'Working on "nested_child_1". Number of steps: 4.', - ' * (0): out_child_1_0', - ' * (1): out_child_1_1', - ' * (2): out_child_1_2', - ' * (3): out_child_1_3', - '', - ' * (1): out_parent_1', - 'Working on "nested_child_2". Number of steps: 4.', - ' * (0): out_child_2_0', - ' * (1): out_child_2_1', - ' * (2): out_child_2_2', - ' * (3): out_child_2_3', - '', - ' * (2): out_parent_2', - ]), $stdOut); + $cmdResult = $this->exec('nested', ['-b' => null, '-vv' => null]); + + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + isSame( + \implode("\n", [ + 'Working on "nested_parent". Number of steps: 3.', + 'Working on "nested_child_0". Number of steps: 4. Level: 2.', + ' * (0): out_child_0_0', + ' * (1): out_child_0_1', + ' * (2): out_child_0_2', + ' * (3): out_child_0_3', + ' * (0): out_parent_0', + 'Working on "nested_child_1". Number of steps: 4. Level: 2.', + ' * (0): out_child_1_0', + ' * (1): out_child_1_1', + ' * (2): out_child_1_2', + ' * (3): out_child_1_3', + ' * (1): out_parent_1', + 'Working on "nested_child_2". Number of steps: 4. Level: 2.', + ' * (0): out_child_2_0', + ' * (1): out_child_2_1', + ' * (2): out_child_2_2', + ' * (3): out_child_2_3', + ' * (2): out_parent_2', + ]), + $cmdResult->std, + ); + } + + public function testCatchMode(): void + { + $cmdResult = $this->exec('catch-mode'); + + isSame(0, $cmdResult->code); + isSame('', $cmdResult->err); + isSame( + \implode("\n", [ + 'Working on "catch-mode". Number of steps: 3.', + ' * (0): Regular return 0; _(); cli(); echo', + ' * (1): Regular return 1; _(); cli(); echo', + ' * (2): Regular return 2; _(); cli(); echo', + ]), + $cmdResult->std, + ); } - private function exec(string $testCase, array $addOptions = [], bool $noProgress = true): array + private function exec(string $testCase, array $addOptions = [], bool $noProgress = true): CmdResult { if ($noProgress) { $options['no-progress'] = null; } $options['case'] = $testCase; - $options = \array_merge($options, $addOptions); + + $options = \array_merge($options, $addOptions); return Helper::executeReal('test:progress', $options); } diff --git a/tests/CliStdInTest.php b/tests/CliStdInTest.php index 0eddc86..243d843 100644 --- a/tests/CliStdInTest.php +++ b/tests/CliStdInTest.php @@ -22,7 +22,7 @@ class CliStdInTest extends PHPUnit { public function testStdInEmpty(): void { - isSame('', Helper::executeReal('test:cli-stdin')[1]); + isSame('', Helper::executeReal('test:cli-stdin')->std); } public function testStdInNotEmpty(): void @@ -30,7 +30,7 @@ public function testStdInNotEmpty(): void $ramdom = Str::random(); isSame( "string(11) \"{$ramdom}\n\"", - Helper::executeReal('test:cli-stdin', ['var-dump' => null], "echo \"{$ramdom}\" | ")[1], + Helper::executeReal('test:cli-stdin', ['var-dump' => null], "echo \"{$ramdom}\" | ")->std, ); } @@ -39,7 +39,7 @@ public function testStdInFile(): void $file = __FILE__; isSame( \trim(\file_get_contents($file)), - Helper::executeReal('test:cli-stdin', [], "cat \"{$file}\" | ")[1], + Helper::executeReal('test:cli-stdin', [], "cat \"{$file}\" | ")->std, ); } @@ -47,7 +47,7 @@ public function testStdInSpaces(): void { isSame( "string(2) \" \n\"", - Helper::executeReal('test:cli-stdin', ['var-dump' => null], 'echo " " | ')[1], + Helper::executeReal('test:cli-stdin', ['var-dump' => null], 'echo " " | ')->std, ); } } diff --git a/tests/CmdResult.php b/tests/CmdResult.php new file mode 100644 index 0000000..4e3dbc2 --- /dev/null +++ b/tests/CmdResult.php @@ -0,0 +1,50 @@ +code = $exitCode; + $this->std = $stdOut; + $this->err = $stdErr; + $this->cmd = $command; + } + + public function __toString(): string + { + return \print_r($this, true); + } + + public function stdJson(): JSON + { + return new JSON($this->std); + } + + public function errJson(): JSON + { + return new JSON($this->err); + } +} diff --git a/tests/Helper.php b/tests/Helper.php index 1108bbe..2cccb35 100644 --- a/tests/Helper.php +++ b/tests/Helper.php @@ -17,6 +17,7 @@ namespace JBZoo\PHPUnit; use JBZoo\Cli\CliApplication; +use JBZoo\Data\JSON; use JBZoo\PHPUnit\TestApp\Commands\TestCliOptions; use JBZoo\PHPUnit\TestApp\Commands\TestCliStdIn; use JBZoo\PHPUnit\TestApp\Commands\TestProgress; @@ -28,34 +29,42 @@ use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Process\Process; -class Helper extends PHPUnit +use function JBZoo\Data\json; + +class Helper { public static function executeReal( string $command, array $options = [], string $preAction = '', string $postAction = '', - ): array { + ): CmdResult { $cwd = __DIR__ . '/TestApp'; $options['no-ansi'] = null; - $realCommand = \trim(\implode(' ', [ - $preAction, - Env::string('PHP_BIN', 'php'), - Cli::build("{$cwd}/cli-wrapper.php {$command}", $options), - '', - $postAction, - ])); + $realCommand = \trim( + \implode(' ', [ + $preAction, + Env::string('PHP_BIN', 'php'), + Cli::build("{$cwd}/cli-wrapper.php {$command}", $options), + '', + $postAction, + ]), + ); - // dump($realCommand); $process = Process::fromShellCommandline($realCommand, $cwd, null, null, 3600); $process->run(); - return [$process->getExitCode(), \trim($process->getOutput()), \trim($process->getErrorOutput())]; + return new CmdResult( + $process->getExitCode(), + \trim($process->getOutput()), + \trim($process->getErrorOutput()), + $realCommand, + ); } - public static function executeVirtaul(string $command, array $options = []): string + public static function executeVirtaul(string $command, array $options = []): CmdResult { $rootPath = \dirname(__DIR__); @@ -78,6 +87,49 @@ public static function executeVirtaul(string $command, array $options = []): str throw new Exception($buffer->fetch()); } - return $buffer->fetch(); + return new CmdResult(0, $buffer->fetch(), '', (string)$inputString); + } + + public static function validateDateFormat(string $logMessage): bool + { + // Example: [2023-08-05T17:31:57.421918+04:00] + $pattern = '/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\+\d{2}:\d{2}\]/'; + \preg_match($pattern, $logMessage, $matches); + + return \count($matches) === 1; + } + + public static function validateProfilerFormat(string $logMessage): bool + { + // Example: [+0.057s/ 15.44 KB] + $pattern = '/\[\+\d+\.\d+s\/\s+\d+\.\d+ KB\]/'; + \preg_match($pattern, $logMessage, $matches); + + return \count($matches) === 1; + } + + public static function assertLogstash(array $expected, JSON $stdOutput): void + { + isSame( + $expected, + [$stdOutput->get('level'), $stdOutput->get('message')], + "Expected: ['{$stdOutput->get('level')}', '{$stdOutput->get('message')}']", + ); + } + + /** + * @return JSON[] + */ + public static function prepareLogstash(string $output): array + { + $lines = \explode("\n", $output); + + $result = []; + + foreach ($lines as $line) { + $result[] = json($line); + } + + return $result; } } diff --git a/tests/TestApp/Commands/TestCliOptions.php b/tests/TestApp/Commands/TestCliOptions.php index fa29779..cba5620 100644 --- a/tests/TestApp/Commands/TestCliOptions.php +++ b/tests/TestApp/Commands/TestCliOptions.php @@ -54,9 +54,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { $options = [ @@ -72,7 +69,7 @@ protected function executeAction(): int foreach ($options as $option) { $result[$option] = [ - 'Default' => $this->helper->getInput()->getOption($option), + 'Default' => $this->outputMode->getInput()->getOption($option), 'Bool' => $this->getOptBool($option), 'Int' => $this->getOptInt($option), 'Float' => $this->getOptFloat($option), diff --git a/tests/TestApp/Commands/TestCliStdIn.php b/tests/TestApp/Commands/TestCliStdIn.php index a1f5000..2220510 100644 --- a/tests/TestApp/Commands/TestCliStdIn.php +++ b/tests/TestApp/Commands/TestCliStdIn.php @@ -32,9 +32,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { if ($this->getOptBool('var-dump')) { diff --git a/tests/TestApp/Commands/TestException.php b/tests/TestApp/Commands/TestException.php index cdf811b..7923c1a 100644 --- a/tests/TestApp/Commands/TestException.php +++ b/tests/TestApp/Commands/TestException.php @@ -30,9 +30,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { throw new Exception('Error message'); diff --git a/tests/TestApp/Commands/TestOutput.php b/tests/TestApp/Commands/TestOutput.php index aab3f61..5787747 100644 --- a/tests/TestApp/Commands/TestOutput.php +++ b/tests/TestApp/Commands/TestOutput.php @@ -21,6 +21,8 @@ use JBZoo\Cli\OutLvl; use Symfony\Component\Console\Input\InputOption; +use function JBZoo\Cli\cli; + class TestOutput extends CliCommand { /** @@ -36,9 +38,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { if ($this->getOptBool('type-of-vars')) { @@ -64,8 +63,8 @@ protected function executeAction(): int $this->_(['Normal 1', 'Normal 2']); $this->_('Message', OutLvl::ERROR); - $this->_('Info1 -v', OutLvl::V); - $this->_('Info2 -v', OutLvl::INFO); + cli('Info1 -v', OutLvl::V); + cli('Info2 -v', OutLvl::INFO); $this->_('Verbose1 -vv', OutLvl::VV); $this->_('Verbose2 -vv', OutLvl::WARNING); @@ -80,6 +79,8 @@ protected function executeAction(): int $this->_('Error (error)', OutLvl::ERROR); $this->_('Error (exception)', OutLvl::EXCEPTION); + $this->_('Message with context', OutLvl::DEBUG, ['foo' => 'bar']); + $this->_('Quiet -q', OutLvl::Q); if ($exception = $this->getOptString('exception')) { diff --git a/tests/TestApp/Commands/TestProgress.php b/tests/TestApp/Commands/TestProgress.php index 0411cb8..308e0dc 100644 --- a/tests/TestApp/Commands/TestProgress.php +++ b/tests/TestApp/Commands/TestProgress.php @@ -18,9 +18,12 @@ use JBZoo\Cli\CliCommand; use JBZoo\Cli\Exception; +use JBZoo\Cli\ProgressBars\ExceptionBreak; use JBZoo\Cli\ProgressBars\ProgressBar; +use JBZoo\Cli\ProgressBars\ProgressBarSymfony; use Symfony\Component\Console\Input\InputOption; +use function JBZoo\Cli\cli; use function JBZoo\Data\json; class TestProgress extends CliCommand @@ -39,107 +42,138 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { $testCase = $this->getOptString('case'); - if ($testCase === 'no-items-int') { - ProgressBar::run(0, static function (): void { - }, $testCase); + if ($testCase === 'minimal') { + // Static call as backwards capability + ProgressBar::run(2, function (): void { + \sleep($this->getOptInt('sleep')); + }); } - if ($testCase === 'no-items-array') { - ProgressBar::run([], static function (): void { + if ($testCase === 'one-message') { + $this->progressBar(3, static function ($listValue, $listKey, $stepIndex) { + if ($listValue === 1) { + return "{$listValue}, {$listKey}, {$stepIndex}"; + } }, $testCase); } - if ($testCase === 'no-items-data') { - ProgressBar::run(json(), static function (): void { - }, $testCase); + if ($testCase === 'array-assoc') { + $list = ['key_1' => 'value_1', 'key_2' => 'value_2']; + $this->progressBar( + $list, + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", + $testCase, + ); } - if ($testCase === 'minimal') { - ProgressBar::run(2, function (): void { - \sleep($this->getOptInt('sleep')); - }); + if ($testCase === 'no-items-int') { + $this->progressBar(0, static function (): void { }, $testCase); } - if ($testCase === 'no-messages') { - ProgressBar::run(3, static function ($stepValue, $stepIndex, $currentStep): void { - }, $testCase); + if ($testCase === 'no-items-array') { + $this->progressBar([], static function (): void { }, $testCase); } - if ($testCase === 'one-message') { - ProgressBar::run(3, static function ($stepValue, $stepIndex, $currentStep) { - if ($stepValue === 1) { - return "{$stepValue}, {$stepIndex}, {$currentStep}"; - } + if ($testCase === 'no-items-data') { + $this->progressBar(json(), static function (): void { }, $testCase); + } + + // // old tests + + if ($testCase === 'no-messages') { + $this->progressBar(3, static function ($listValue, $listKey, $stepIndex): void { }, $testCase); } if ($testCase === 'simple-message-all') { - ProgressBar::run(3, static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", $testCase); + $this->progressBar( + 3, + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", + $testCase, + ); } if ($testCase === 'output-as-array') { $list = ['key_1' => 'value_1', 'key_2' => 'value_2']; - ProgressBar::run($list, static fn ($stepValue, $stepIndex, $currentStep) => [$stepValue, $stepIndex, $currentStep], $testCase); + $this->progressBar( + $list, + static fn ($listValue, $listKey, $stepIndex) => [$listValue, $listKey, $stepIndex], + $testCase, + ); } if ($testCase === 'array-int') { - ProgressBar::run([4, 5, 6], static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", $testCase); + $this->progressBar( + [4, 5, 6], + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", + $testCase, + ); } if ($testCase === 'array-string') { - ProgressBar::run(['qwerty', 'asdfgh'], static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", $testCase); - } - - if ($testCase === 'array-assoc') { - $list = ['key_1' => 'value_1', 'key_2' => 'value_2']; - ProgressBar::run($list, static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", $testCase); + $this->progressBar( + ['qwerty', 'asdfgh'], + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", + $testCase, + ); } if ($testCase === 'data') { $list = json(['key_1' => 'value_1', 'key_2' => 'value_2']); - ProgressBar::run($list, static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", $testCase); + $this->progressBar( + $list, + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", + $testCase, + ); } if ($testCase === 'break') { - ProgressBar::run(3, static function ($stepValue) { - if ($stepValue === 1) { - return ProgressBar::BREAK; + $this->progressBar(3, static function ($listValue) { + if ($listValue === 1) { + return ProgressBarSymfony::BREAK; } - return $stepValue; + return $listValue; + }, $testCase); + } + + if ($testCase === 'break-exception') { + $this->progressBar(3, static function ($listValue) { + if ($listValue === 1) { + throw new ExceptionBreak("Something went wrong with \$listValue={$listValue}"); + } + + return $listValue; }, $testCase); } if ($testCase === 'exception') { - ProgressBar::run(3, static function ($stepValue): void { - if ($stepValue === 1) { - throw new \Exception("Exception #{$stepValue}"); + $this->progressBar(3, static function ($listValue): void { + if ($listValue === 1) { + throw new \Exception("Exception #{$listValue}"); } }, $testCase, $this->getOptBool('batch-exception')); } if ($testCase === 'exception-list') { - ProgressBar::run(10, static function ($stepValue): void { - if ($stepValue % 3 === 0) { - throw new \Exception("Exception #{$stepValue}"); + $this->progressBar(10, static function ($listValue): void { + if ($listValue % 3 === 0) { + throw new \RuntimeException("Exception #{$listValue}"); } }, $testCase, $this->getOptBool('batch-exception')); } if ($testCase === 'million-items') { - ProgressBar::run(100000, static fn ($stepValue) => $stepValue, $testCase); + $this->progressBar(100000, static fn ($listValue) => $listValue, $testCase); } if ($testCase === 'memory-leak') { $array = []; - ProgressBar::run(3, function () use (&$array): void { + $this->progressBar(3, function () use (&$array): void { for ($i = 0; $i < 100000; $i++) { $array[] = $i; } @@ -149,23 +183,28 @@ protected function executeAction(): int } if ($testCase === 'nested') { - $array = []; - $parentSection = $this->helper->getOutput()->section(); - $childSection = $this->helper->getOutput()->section(); - - ProgressBar::run(3, function ($parentId) use ($testCase, $childSection) { + $this->progressBar(3, function ($parentId) use ($testCase) { \sleep($this->getOptInt('sleep')); - ProgressBar::run(4, function ($childId) use ($parentId) { + $this->progressBar(4, function ($childId) use ($parentId) { \sleep($this->getOptInt('sleep')); return "out_child_{$parentId}_{$childId}"; - }, "{$testCase}_child_{$parentId}", false, $childSection); - - $childSection->clear(); + }, "{$testCase}_child_{$parentId}", false); return "out_parent_{$parentId}"; - }, "{$testCase}_parent", false, $parentSection); + }, "{$testCase}_parent", false); + } + + if ($testCase === 'catch-mode') { + $this->progressBar(3, function ($index) { + echo 'echo'; + $this->_('_()'); + cli('cli()'); + \sleep($this->getOptInt('sleep')); + + return "Regular return {$index}"; + }, $testCase, false); } if (!$testCase) { diff --git a/tests/TestApp/Commands/TestSleep.php b/tests/TestApp/Commands/TestSleep.php index 0bfb14e..ff50764 100644 --- a/tests/TestApp/Commands/TestSleep.php +++ b/tests/TestApp/Commands/TestSleep.php @@ -34,9 +34,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeAction(): int { $this->_('Started'); diff --git a/tests/TestApp/Commands/TestSleepMulti.php b/tests/TestApp/Commands/TestSleepMulti.php index 95e1cc8..79e3409 100644 --- a/tests/TestApp/Commands/TestSleepMulti.php +++ b/tests/TestApp/Commands/TestSleepMulti.php @@ -43,9 +43,6 @@ protected function configure(): void parent::configure(); } - /** - * {@inheritDoc} - */ protected function executeOneProcess(string $pmThreadId): int { $sleep = $this->getOptInt('sleep'); @@ -60,9 +57,9 @@ protected function executeOneProcess(string $pmThreadId): int $this->_([ "Started: {$pmThreadId}", 'Sleep : ' . $sleep, - 'Arg #1: ' . $this->helper->getInput()->getArgument('arg-1'), - 'Arg #2: ' . $this->helper->getInput()->getArgument('arg-2'), - 'Arg #3: ' . $this->helper->getInput()->getArgument('arg-3'), + 'Arg #1: ' . $this->outputMode->getInput()->getArgument('arg-1'), + 'Arg #2: ' . $this->outputMode->getInput()->getArgument('arg-2'), + 'Arg #3: ' . $this->outputMode->getInput()->getArgument('arg-3'), 'Env Var: ' . Env::string('JBZOO_TEST_VAR'), ]); @@ -90,10 +87,10 @@ protected function afterFinishAllProcesses(array $procPool): void { $result = []; - foreach ($procPool as $procId => $procInfo) { + foreach ($procPool as $procInfo) { $result[] = $procInfo['std_out']; } - $this->_(json($result)); + $this->_((string)json($result)); } } diff --git a/tests/TestApp/bin.php b/tests/TestApp/bin.php index 58861b0..2a9b94d 100644 --- a/tests/TestApp/bin.php +++ b/tests/TestApp/bin.php @@ -26,24 +26,7 @@ \define('JBZOO_PATH_BIN', JBZOO_PATH_ROOT . '/' . \pathinfo(__FILE__, \PATHINFO_BASENAME)); } -$composerAutoloadFiles = \array_values( - \array_filter([ - \realpath(JBZOO_PATH_ROOT . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 1) . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 2) . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 3) . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 4) . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 5) . '/vendor/autoload.php'), - \realpath(\dirname(JBZOO_PATH_ROOT, 6) . '/vendor/autoload.php'), - ]), -); - -$composerAutoloadFile = $composerAutoloadFiles[0] ?? null; -if ($composerAutoloadFile) { - require_once $composerAutoloadFile; -} else { - throw new \RuntimeException('Composer autoload file not found'); -} +require '../../vendor/autoload.php'; $application = new CliApplication('Dummy App', '@git-version@'); $application->registerCommandsByPath(JBZOO_PATH_ROOT . '/Commands', __NAMESPACE__);