Skip to content

feat: improve CLI input testability #7978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Nov 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <admin@codeigniter.com>
*
* 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 <admin@codeigniter.com>
*
* 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