diff --git a/system/Test/Filters/CITestStreamFilter.php b/system/Test/Filters/CITestStreamFilter.php index 538c0741ba8d..fa91aa69ef55 100644 --- a/system/Test/Filters/CITestStreamFilter.php +++ b/system/Test/Filters/CITestStreamFilter.php @@ -92,6 +92,22 @@ public static function removeOutputFilter(): void self::removeFilter(self::$out); } + /** + * Whether an output filter is currently attached to STDOUT. + */ + public static function hasOutputFilter(): bool + { + return self::$out !== null; + } + + /** + * Whether an error filter is currently attached to STDERR. + */ + public static function hasErrorFilter(): bool + { + return self::$err !== null; + } + /** * @param resource|null $stream * diff --git a/system/Test/Mock/MockInputOutput.php b/system/Test/Mock/MockInputOutput.php index 6aa4779fb742..9bf8a4f54810 100644 --- a/system/Test/Mock/MockInputOutput.php +++ b/system/Test/Mock/MockInputOutput.php @@ -35,6 +35,16 @@ final class MockInputOutput extends InputOutput */ private array $outputs = []; + /** + * Snapshot of the shared CITestStreamFilter state captured before this + * object attaches its own filters, restored once it is done. Lets a test + * combine MockInputOutput with an enclosing StreamFilterTrait without the + * latter's filters being torn down. + * + * @var array{output: bool, error: bool, buffer: string}|null + */ + private ?array $priorFilterState = null; + /** * Sets user inputs. * @@ -88,6 +98,12 @@ public function getOutputs(): array private function addStreamFilters(): void { + $this->priorFilterState = [ + 'output' => CITestStreamFilter::hasOutputFilter(), + 'error' => CITestStreamFilter::hasErrorFilter(), + 'buffer' => CITestStreamFilter::$buffer, + ]; + CITestStreamFilter::registration(); CITestStreamFilter::addOutputFilter(); CITestStreamFilter::addErrorFilter(); @@ -97,6 +113,22 @@ private function removeStreamFilters(): void { CITestStreamFilter::removeOutputFilter(); CITestStreamFilter::removeErrorFilter(); + + if ($this->priorFilterState === null) { + return; + } + + CITestStreamFilter::$buffer = $this->priorFilterState['buffer']; + + if ($this->priorFilterState['output']) { + CITestStreamFilter::addOutputFilter(); + } + + if ($this->priorFilterState['error']) { + CITestStreamFilter::addErrorFilter(); + } + + $this->priorFilterState = null; } public function input(?string $prefix = null): string diff --git a/tests/system/Test/Mock/MockInputOutputTest.php b/tests/system/Test/Mock/MockInputOutputTest.php new file mode 100644 index 000000000000..9ac7aefb547a --- /dev/null +++ b/tests/system/Test/Mock/MockInputOutputTest.php @@ -0,0 +1,68 @@ + + * + * 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\CLI; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class MockInputOutputTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + protected function tearDown(): void + { + parent::tearDown(); + + CLI::resetInputOutput(); + } + + public function testFwriteThroughMockPreservesEnclosingStreamFilter(): void + { + CLI::write('before mock'); + $this->assertStringContainsString('before mock', $this->getStreamFilterBuffer()); + + $io = new MockInputOutput(); + CLI::setInputOutput($io); + CLI::write('through mock'); + CLI::resetInputOutput(); + + // The mock captured its own write into its own buffer... + $this->assertStringContainsString('through mock', $io->getOutput()); + // ...and left the enclosing StreamFilterTrait buffer untouched. + $this->assertStringNotContainsString('through mock', $this->getStreamFilterBuffer()); + + // The enclosing filter is still attached, so later writes are captured. + CLI::write('after mock'); + $this->assertStringContainsString('before mock', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('after mock', $this->getStreamFilterBuffer()); + } + + public function testInputThroughMockPreservesEnclosingStreamFilter(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + CLI::prompt('Continue?', ['y', 'n']); + CLI::resetInputOutput(); + + CLI::write('after prompt'); + $this->assertStringContainsString('after prompt', $this->getStreamFilterBuffer()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.4.rst b/user_guide_src/source/changelogs/v4.7.4.rst index f9cc995ad22a..41ecf19e9b04 100644 --- a/user_guide_src/source/changelogs/v4.7.4.rst +++ b/user_guide_src/source/changelogs/v4.7.4.rst @@ -34,6 +34,7 @@ Bugs Fixed - **Database:** Fixed a bug where ``updateBatch()`` could be called after Query Builder ``where()`` conditions, even though it's not supported. In this situation, now the ``DatabaseException`` is thrown. - **HTTP:** Fixed a bug where the User Agent library reported Safari's WebKit version instead of the browser version from the ``Version`` token. - **Model:** Fixed a bug in ``Model::objectToRawArray()`` where the ``$recursive`` parameter was ignored. +- **Testing:** Fixed a bug where using ``MockInputOutput`` within a test that also uses ``StreamFilterTrait`` tore down the trait's stream filters, so CLI output produced after the ``MockInputOutput`` interaction (such as in ``tearDown()``) was no longer captured and leaked to the console. See the repo's `CHANGELOG.md `_