Skip to content

Commit

Permalink
Merge pull request #7978 from kenjis/feat-cli-io
Browse files Browse the repository at this point in the history
feat: improve CLI input testability
  • Loading branch information
kenjis authored Nov 5, 2023
2 parents 573be07 + 0321cc0 commit cf6b9da
Show file tree
Hide file tree
Showing 9 changed files with 518 additions and 121 deletions.
68 changes: 30 additions & 38 deletions system/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,6 @@
* possible to test using travis-ci. It has been phpunit-annotated
* to prevent messing up code coverage.
*
* Some of the methods require keyboard input, and are not unit-testable
* as a result: input() and prompt().
* validate() is internal, and not testable if prompt() isn't.
* The wait() method is mostly testable, as long as you don't give it
* an argument of "0".
* These have been flagged to ignore for code coverage purposes.
*
* @see \CodeIgniter\CLI\CLITest
*/
class CLI
Expand All @@ -43,7 +36,7 @@ class CLI
*
* @var bool
*
* @deprecated 4.4.2 Should be protected.
* @deprecated 4.4.2 Should be protected, and no longer used.
* @TODO Fix to camelCase in the next major version.
*/
public static $readline_support = false;
Expand Down Expand Up @@ -152,6 +145,11 @@ class CLI
*/
protected static $isColored = false;

/**
* Input and Output for CLI.
*/
protected static ?InputOutput $io = null;

/**
* Static "constructor".
*
Expand Down Expand Up @@ -181,6 +179,8 @@ public static function init()
// For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047
define('STDOUT', 'php://output'); // @codeCoverageIgnore
}

static::resetInputOutput();
}

/**
Expand All @@ -193,14 +193,7 @@ public static function init()
*/
public static function input(?string $prefix = null): string
{
// readline() can't be tested.
if (static::$readline_support && ENVIRONMENT !== 'testing') {
return readline($prefix); // @codeCoverageIgnore
}

echo $prefix;

return fgets(fopen('php://stdin', 'rb'));
return static::$io->input($prefix);
}

/**
Expand All @@ -225,8 +218,6 @@ public static function input(?string $prefix = null): string
* @param array|string|null $validation Validation rules
*
* @return string The user input
*
* @codeCoverageIgnore
*/
public static function prompt(string $field, $options = null, $validation = null): string
{
Expand Down Expand Up @@ -265,7 +256,7 @@ public static function prompt(string $field, $options = null, $validation = null
static::fwrite(STDOUT, $field . (trim($field) ? ' ' : '') . $extraOutput . ': ');

// Read the input from keyboard.
$input = trim(static::input()) ?: $default;
$input = trim(static::$io->input()) ?: $default;

if ($validation !== []) {
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
Expand All @@ -285,8 +276,6 @@ public static function prompt(string $field, $options = null, $validation = null
* @param array|string|null $validation Validation rules
*
* @return string The selected key of $options
*
* @codeCoverageIgnore
*/
public static function promptByKey($text, array $options, $validation = null): string
{
Expand Down Expand Up @@ -415,8 +404,6 @@ private static function printKeysAndValues(array $options): void
* @param string $field Prompt "field" output
* @param string $value Input value
* @param array|string $rules Validation rules
*
* @codeCoverageIgnore
*/
protected static function validate(string $field, string $value, $rules): bool
{
Expand Down Expand Up @@ -533,11 +520,8 @@ public static function wait(int $seconds, bool $countdown = false)
} elseif ($seconds > 0) {
sleep($seconds);
} else {
// this chunk cannot be tested because of keyboard input
// @codeCoverageIgnoreStart
static::write(static::$wait_msg);
static::input();
// @codeCoverageIgnoreEnd
static::$io->input();
}
}

Expand Down Expand Up @@ -567,8 +551,6 @@ public static function newLine(int $num = 1)
/**
* Clears the screen of output
*
* @codeCoverageIgnore
*
* @return void
*/
public static function clearScreen()
Expand Down Expand Up @@ -762,8 +744,6 @@ public static function getHeight(int $default = 32): int
/**
* Populates the CLI's dimensions.
*
* @codeCoverageIgnore
*
* @return void
*/
public static function generateDimensions()
Expand Down Expand Up @@ -1137,15 +1117,27 @@ public static function table(array $tbody, array $thead = [])
*/
protected static function fwrite($handle, string $string)
{
if (! is_cli()) {
// @codeCoverageIgnoreStart
echo $string;
static::$io->fwrite($handle, $string);
}

return;
// @codeCoverageIgnoreEnd
}
/**
* Testing purpose only
*
* @testTag
*/
public static function setInputOutput(InputOutput $io): void
{
static::$io = $io;
}

fwrite($handle, $string);
/**
* Testing purpose only
*
* @testTag
*/
public static function resetInputOutput(): void
{
static::$io = new InputOutput();
}
}

Expand Down
80 changes: 80 additions & 0 deletions system/CLI/InputOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\CLI;

/**
* Input and Output for CLI.
*/
class InputOutput
{
/**
* Is the readline library on the system?
*/
private bool $readlineSupport;

public function __construct()
{
// Readline is an extension for PHP that makes interactivity with PHP
// much more bash-like.
// http://www.php.net/manual/en/readline.installation.php
$this->readlineSupport = extension_loaded('readline');
}

/**
* Get input from the shell, using readline or the standard STDIN
*
* Named options must be in the following formats:
* php index.php user -v --v -name=John --name=John
*
* @param string|null $prefix You may specify a string with which to prompt the user.
*/
public function input(?string $prefix = null): string
{
// readline() can't be tested.
if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
return readline($prefix); // @codeCoverageIgnore
}

echo $prefix;

$input = fgets(fopen('php://stdin', 'rb'));

if ($input === false) {
$input = '';
}

return $input;
}

/**
* While the library is intended for use on CLI commands,
* commands can be called from controllers and elsewhere
* so we need a way to allow them to still work.
*
* For now, just echo the content, but look into a better
* solution down the road.
*
* @param resource $handle
*/
public function fwrite($handle, string $string): void
{
if (! is_cli()) {
echo $string;

return;
}

fwrite($handle, $string);
}
}
140 changes: 140 additions & 0 deletions system/Test/Mock/MockInputOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Test\Mock;

use CodeIgniter\CLI\InputOutput;
use CodeIgniter\Test\Filters\CITestStreamFilter;
use CodeIgniter\Test\PhpStreamWrapper;
use InvalidArgumentException;
use LogicException;

final class MockInputOutput extends InputOutput
{
/**
* String to be entered by the user.
*
* @var list<string>
*/
private array $inputs = [];

/**
* Output lines.
*
* @var array<int, string>
* @phpstan-var list<string>
*/
private array $outputs = [];

/**
* Sets user inputs.
*
* @param array<int, string> $inputs
* @phpstan-param list<string> $inputs
*/
public function setInputs(array $inputs): void
{
$this->inputs = $inputs;
}

/**
* Gets the item from the output array.
*
* @param int|null $index The output array index. If null, returns all output
* string. If negative int, returns the last $index-th
* item.
*/
public function getOutput(?int $index = null): string
{
if ($index === null) {
return implode('', $this->outputs);
}

if (array_key_exists($index, $this->outputs)) {
return $this->outputs[$index];
}

if ($index < 0) {
$i = count($this->outputs) + $index;

if (array_key_exists($i, $this->outputs)) {
return $this->outputs[$i];
}
}

throw new InvalidArgumentException(
'No such index in output: ' . $index . ', the last index is: '
. (count($this->outputs) - 1)
);
}

/**
* Returns the outputs array.
*/
public function getOutputs(): array
{
return $this->outputs;
}

private function addStreamFilters(): void
{
CITestStreamFilter::registration();
CITestStreamFilter::addOutputFilter();
CITestStreamFilter::addErrorFilter();
}

private function removeStreamFilters(): void
{
CITestStreamFilter::removeOutputFilter();
CITestStreamFilter::removeErrorFilter();
}

public function input(?string $prefix = null): string
{
if ($this->inputs === []) {
throw new LogicException(
'No input data. Specifiy input data with `MockInputOutput::setInputs()`.'
);
}

$input = array_shift($this->inputs);

$this->addStreamFilters();

PhpStreamWrapper::register();
PhpStreamWrapper::setContent($input);

$userInput = parent::input($prefix);
$this->outputs[] = CITestStreamFilter::$buffer . $input . PHP_EOL;

PhpStreamWrapper::restore();

$this->removeStreamFilters();

if ($input !== $userInput) {
throw new LogicException($input . '!==' . $userInput);
}

return $input;
}

public function fwrite($handle, string $string): void
{
$this->addStreamFilters();

parent::fwrite($handle, $string);
$this->outputs[] = CITestStreamFilter::$buffer;

$this->removeStreamFilters();
}
}
Loading

0 comments on commit cf6b9da

Please sign in to comment.