Skip to content

Commit cf6b9da

Browse files
authored
Merge pull request #7978 from kenjis/feat-cli-io
feat: improve CLI input testability
2 parents 573be07 + 0321cc0 commit cf6b9da

File tree

9 files changed

+518
-121
lines changed

9 files changed

+518
-121
lines changed

system/CLI/CLI.php

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@
2727
* possible to test using travis-ci. It has been phpunit-annotated
2828
* to prevent messing up code coverage.
2929
*
30-
* Some of the methods require keyboard input, and are not unit-testable
31-
* as a result: input() and prompt().
32-
* validate() is internal, and not testable if prompt() isn't.
33-
* The wait() method is mostly testable, as long as you don't give it
34-
* an argument of "0".
35-
* These have been flagged to ignore for code coverage purposes.
36-
*
3730
* @see \CodeIgniter\CLI\CLITest
3831
*/
3932
class CLI
@@ -43,7 +36,7 @@ class CLI
4336
*
4437
* @var bool
4538
*
46-
* @deprecated 4.4.2 Should be protected.
39+
* @deprecated 4.4.2 Should be protected, and no longer used.
4740
* @TODO Fix to camelCase in the next major version.
4841
*/
4942
public static $readline_support = false;
@@ -152,6 +145,11 @@ class CLI
152145
*/
153146
protected static $isColored = false;
154147

148+
/**
149+
* Input and Output for CLI.
150+
*/
151+
protected static ?InputOutput $io = null;
152+
155153
/**
156154
* Static "constructor".
157155
*
@@ -181,6 +179,8 @@ public static function init()
181179
// For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047
182180
define('STDOUT', 'php://output'); // @codeCoverageIgnore
183181
}
182+
183+
static::resetInputOutput();
184184
}
185185

186186
/**
@@ -193,14 +193,7 @@ public static function init()
193193
*/
194194
public static function input(?string $prefix = null): string
195195
{
196-
// readline() can't be tested.
197-
if (static::$readline_support && ENVIRONMENT !== 'testing') {
198-
return readline($prefix); // @codeCoverageIgnore
199-
}
200-
201-
echo $prefix;
202-
203-
return fgets(fopen('php://stdin', 'rb'));
196+
return static::$io->input($prefix);
204197
}
205198

206199
/**
@@ -225,8 +218,6 @@ public static function input(?string $prefix = null): string
225218
* @param array|string|null $validation Validation rules
226219
*
227220
* @return string The user input
228-
*
229-
* @codeCoverageIgnore
230221
*/
231222
public static function prompt(string $field, $options = null, $validation = null): string
232223
{
@@ -265,7 +256,7 @@ public static function prompt(string $field, $options = null, $validation = null
265256
static::fwrite(STDOUT, $field . (trim($field) ? ' ' : '') . $extraOutput . ': ');
266257

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

270261
if ($validation !== []) {
271262
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
@@ -285,8 +276,6 @@ public static function prompt(string $field, $options = null, $validation = null
285276
* @param array|string|null $validation Validation rules
286277
*
287278
* @return string The selected key of $options
288-
*
289-
* @codeCoverageIgnore
290279
*/
291280
public static function promptByKey($text, array $options, $validation = null): string
292281
{
@@ -415,8 +404,6 @@ private static function printKeysAndValues(array $options): void
415404
* @param string $field Prompt "field" output
416405
* @param string $value Input value
417406
* @param array|string $rules Validation rules
418-
*
419-
* @codeCoverageIgnore
420407
*/
421408
protected static function validate(string $field, string $value, $rules): bool
422409
{
@@ -533,11 +520,8 @@ public static function wait(int $seconds, bool $countdown = false)
533520
} elseif ($seconds > 0) {
534521
sleep($seconds);
535522
} else {
536-
// this chunk cannot be tested because of keyboard input
537-
// @codeCoverageIgnoreStart
538523
static::write(static::$wait_msg);
539-
static::input();
540-
// @codeCoverageIgnoreEnd
524+
static::$io->input();
541525
}
542526
}
543527

@@ -567,8 +551,6 @@ public static function newLine(int $num = 1)
567551
/**
568552
* Clears the screen of output
569553
*
570-
* @codeCoverageIgnore
571-
*
572554
* @return void
573555
*/
574556
public static function clearScreen()
@@ -762,8 +744,6 @@ public static function getHeight(int $default = 32): int
762744
/**
763745
* Populates the CLI's dimensions.
764746
*
765-
* @codeCoverageIgnore
766-
*
767747
* @return void
768748
*/
769749
public static function generateDimensions()
@@ -1137,15 +1117,27 @@ public static function table(array $tbody, array $thead = [])
11371117
*/
11381118
protected static function fwrite($handle, string $string)
11391119
{
1140-
if (! is_cli()) {
1141-
// @codeCoverageIgnoreStart
1142-
echo $string;
1120+
static::$io->fwrite($handle, $string);
1121+
}
11431122

1144-
return;
1145-
// @codeCoverageIgnoreEnd
1146-
}
1123+
/**
1124+
* Testing purpose only
1125+
*
1126+
* @testTag
1127+
*/
1128+
public static function setInputOutput(InputOutput $io): void
1129+
{
1130+
static::$io = $io;
1131+
}
11471132

1148-
fwrite($handle, $string);
1133+
/**
1134+
* Testing purpose only
1135+
*
1136+
* @testTag
1137+
*/
1138+
public static function resetInputOutput(): void
1139+
{
1140+
static::$io = new InputOutput();
11491141
}
11501142
}
11511143

system/CLI/InputOutput.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\CLI;
15+
16+
/**
17+
* Input and Output for CLI.
18+
*/
19+
class InputOutput
20+
{
21+
/**
22+
* Is the readline library on the system?
23+
*/
24+
private bool $readlineSupport;
25+
26+
public function __construct()
27+
{
28+
// Readline is an extension for PHP that makes interactivity with PHP
29+
// much more bash-like.
30+
// http://www.php.net/manual/en/readline.installation.php
31+
$this->readlineSupport = extension_loaded('readline');
32+
}
33+
34+
/**
35+
* Get input from the shell, using readline or the standard STDIN
36+
*
37+
* Named options must be in the following formats:
38+
* php index.php user -v --v -name=John --name=John
39+
*
40+
* @param string|null $prefix You may specify a string with which to prompt the user.
41+
*/
42+
public function input(?string $prefix = null): string
43+
{
44+
// readline() can't be tested.
45+
if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
46+
return readline($prefix); // @codeCoverageIgnore
47+
}
48+
49+
echo $prefix;
50+
51+
$input = fgets(fopen('php://stdin', 'rb'));
52+
53+
if ($input === false) {
54+
$input = '';
55+
}
56+
57+
return $input;
58+
}
59+
60+
/**
61+
* While the library is intended for use on CLI commands,
62+
* commands can be called from controllers and elsewhere
63+
* so we need a way to allow them to still work.
64+
*
65+
* For now, just echo the content, but look into a better
66+
* solution down the road.
67+
*
68+
* @param resource $handle
69+
*/
70+
public function fwrite($handle, string $string): void
71+
{
72+
if (! is_cli()) {
73+
echo $string;
74+
75+
return;
76+
}
77+
78+
fwrite($handle, $string);
79+
}
80+
}

system/Test/Mock/MockInputOutput.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Test\Mock;
15+
16+
use CodeIgniter\CLI\InputOutput;
17+
use CodeIgniter\Test\Filters\CITestStreamFilter;
18+
use CodeIgniter\Test\PhpStreamWrapper;
19+
use InvalidArgumentException;
20+
use LogicException;
21+
22+
final class MockInputOutput extends InputOutput
23+
{
24+
/**
25+
* String to be entered by the user.
26+
*
27+
* @var list<string>
28+
*/
29+
private array $inputs = [];
30+
31+
/**
32+
* Output lines.
33+
*
34+
* @var array<int, string>
35+
* @phpstan-var list<string>
36+
*/
37+
private array $outputs = [];
38+
39+
/**
40+
* Sets user inputs.
41+
*
42+
* @param array<int, string> $inputs
43+
* @phpstan-param list<string> $inputs
44+
*/
45+
public function setInputs(array $inputs): void
46+
{
47+
$this->inputs = $inputs;
48+
}
49+
50+
/**
51+
* Gets the item from the output array.
52+
*
53+
* @param int|null $index The output array index. If null, returns all output
54+
* string. If negative int, returns the last $index-th
55+
* item.
56+
*/
57+
public function getOutput(?int $index = null): string
58+
{
59+
if ($index === null) {
60+
return implode('', $this->outputs);
61+
}
62+
63+
if (array_key_exists($index, $this->outputs)) {
64+
return $this->outputs[$index];
65+
}
66+
67+
if ($index < 0) {
68+
$i = count($this->outputs) + $index;
69+
70+
if (array_key_exists($i, $this->outputs)) {
71+
return $this->outputs[$i];
72+
}
73+
}
74+
75+
throw new InvalidArgumentException(
76+
'No such index in output: ' . $index . ', the last index is: '
77+
. (count($this->outputs) - 1)
78+
);
79+
}
80+
81+
/**
82+
* Returns the outputs array.
83+
*/
84+
public function getOutputs(): array
85+
{
86+
return $this->outputs;
87+
}
88+
89+
private function addStreamFilters(): void
90+
{
91+
CITestStreamFilter::registration();
92+
CITestStreamFilter::addOutputFilter();
93+
CITestStreamFilter::addErrorFilter();
94+
}
95+
96+
private function removeStreamFilters(): void
97+
{
98+
CITestStreamFilter::removeOutputFilter();
99+
CITestStreamFilter::removeErrorFilter();
100+
}
101+
102+
public function input(?string $prefix = null): string
103+
{
104+
if ($this->inputs === []) {
105+
throw new LogicException(
106+
'No input data. Specifiy input data with `MockInputOutput::setInputs()`.'
107+
);
108+
}
109+
110+
$input = array_shift($this->inputs);
111+
112+
$this->addStreamFilters();
113+
114+
PhpStreamWrapper::register();
115+
PhpStreamWrapper::setContent($input);
116+
117+
$userInput = parent::input($prefix);
118+
$this->outputs[] = CITestStreamFilter::$buffer . $input . PHP_EOL;
119+
120+
PhpStreamWrapper::restore();
121+
122+
$this->removeStreamFilters();
123+
124+
if ($input !== $userInput) {
125+
throw new LogicException($input . '!==' . $userInput);
126+
}
127+
128+
return $input;
129+
}
130+
131+
public function fwrite($handle, string $string): void
132+
{
133+
$this->addStreamFilters();
134+
135+
parent::fwrite($handle, $string);
136+
$this->outputs[] = CITestStreamFilter::$buffer;
137+
138+
$this->removeStreamFilters();
139+
}
140+
}

0 commit comments

Comments
 (0)