diff --git a/Output/ConsoleOutput.php b/Output/ConsoleOutput.php index 2ad3dbcf3..0aab633c0 100644 --- a/Output/ConsoleOutput.php +++ b/Output/ConsoleOutput.php @@ -130,12 +130,27 @@ private function isRunningOS400(): bool */ private function openOutputStream() { + static $stdout; + + if ($stdout) { + return $stdout; + } + if (!$this->hasStdoutSupport()) { - return fopen('php://output', 'w'); + return $stdout = fopen('php://output', 'w'); } // Use STDOUT when possible to prevent from opening too many file descriptors - return \defined('STDOUT') ? \STDOUT : (@fopen('php://stdout', 'w') ?: fopen('php://output', 'w')); + if (!\defined('STDOUT')) { + return $stdout = @fopen('php://stdout', 'w') ?: fopen('php://output', 'w'); + } + + // On Windows, STDOUT is opened in text mode; reopen in binary mode to prevent \n to \r\n conversion + if ('\\' === \DIRECTORY_SEPARATOR) { + return $stdout = @fopen('php://stdout', 'w') ?: \STDOUT; + } + + return $stdout = \STDOUT; } /** @@ -143,11 +158,26 @@ private function openOutputStream() */ private function openErrorStream() { + static $stderr; + + if ($stderr) { + return $stderr; + } + if (!$this->hasStderrSupport()) { - return fopen('php://output', 'w'); + return $stderr = fopen('php://output', 'w'); } // Use STDERR when possible to prevent from opening too many file descriptors - return \defined('STDERR') ? \STDERR : (@fopen('php://stderr', 'w') ?: fopen('php://output', 'w')); + if (!\defined('STDERR')) { + return $stderr = @fopen('php://stderr', 'w') ?: fopen('php://output', 'w'); + } + + // On Windows, STDERR is opened in text mode; reopen in binary mode to prevent \n → \r\n conversion + if ('\\' === \DIRECTORY_SEPARATOR) { + return $stderr = @fopen('php://stderr', 'w') ?: \STDERR; + } + + return $stderr ??= \STDERR; } } diff --git a/Tester/ApplicationTester.php b/Tester/ApplicationTester.php index a6dc8e1ce..5b1aeab4c 100644 --- a/Tester/ApplicationTester.php +++ b/Tester/ApplicationTester.php @@ -58,6 +58,28 @@ public function run(array $input, array $options = []): int $this->initOutput($options); - return $this->statusCode = $this->application->run($this->input, $this->output); + // Temporarily clear SHELL_VERBOSITY to prevent Application::configureIO + // from overriding the interactive and verbosity settings set above + $prevShellVerbosity = [getenv('SHELL_VERBOSITY'), $_ENV['SHELL_VERBOSITY'] ?? false, $_SERVER['SHELL_VERBOSITY'] ?? false]; + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY'); + } + unset($_ENV['SHELL_VERBOSITY'], $_SERVER['SHELL_VERBOSITY']); + + try { + return $this->statusCode = $this->application->run($this->input, $this->output); + } finally { + if (false !== $prevShellVerbosity[0]) { + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY='.$prevShellVerbosity[0]); + } + } + if (false !== $prevShellVerbosity[1]) { + $_ENV['SHELL_VERBOSITY'] = $prevShellVerbosity[1]; + } + if (false !== $prevShellVerbosity[2]) { + $_SERVER['SHELL_VERBOSITY'] = $prevShellVerbosity[2]; + } + } } } diff --git a/Tests/Fixtures/binary_output.php b/Tests/Fixtures/binary_output.php new file mode 100644 index 000000000..2720e9734 --- /dev/null +++ b/Tests/Fixtures/binary_output.php @@ -0,0 +1,13 @@ +write("HELLO\nWORLD", false, OutputInterface::OUTPUT_RAW); diff --git a/Tests/Output/StreamOutputTest.php b/Tests/Output/StreamOutputTest.php index f8c9913bc..618c44f18 100644 --- a/Tests/Output/StreamOutputTest.php +++ b/Tests/Output/StreamOutputTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\Output; use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Process\Process; class StreamOutputTest extends TestCase { @@ -65,4 +66,12 @@ public function testDoWriteOnFailure() rewind($output->getStream()); $this->assertEquals('', stream_get_contents($output->getStream())); } + + public function testRawOutputPreservesNewlinesInContent() + { + $process = new Process(['php', __DIR__.'/../Fixtures/binary_output.php']); + $process->run(); + + $this->assertSame("HELLO\nWORLD", $process->getOutput(), 'Raw output must not convert LF in binary content'); + } } diff --git a/Tests/Tester/ApplicationTesterTest.php b/Tests/Tester/ApplicationTesterTest.php index 843f2eac7..24fc392bd 100644 --- a/Tests/Tester/ApplicationTesterTest.php +++ b/Tests/Tester/ApplicationTesterTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Tests\Tester; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Helper\QuestionHelper; @@ -91,6 +92,48 @@ public function testGetStatusCode() $this->tester->assertCommandIsSuccessful('->getStatusCode() returns the status code'); } + #[DataProvider('provideShellVerbositySources')] + public function testShellVerbosityDoesNotOverrideInteractiveAndVerbosity(callable $setShellVerbosity, callable $cleanUp) + { + $setShellVerbosity(); + + try { + $application = new Application(); + $application->setAutoExit(false); + $application->register('foo') + ->setCode(static function (InputInterface $input, OutputInterface $output): int { + $output->writeln('foo'); + + return 0; + }) + ; + + $tester = new ApplicationTester($application); + $tester->run(['command' => 'foo'], ['interactive' => true]); + + $this->assertTrue($tester->getInput()->isInteractive()); + $this->assertSame('foo'.\PHP_EOL, $tester->getDisplay()); + } finally { + $cleanUp(); + } + } + + public static function provideShellVerbositySources(): iterable + { + yield 'putenv' => [ + static function () { putenv('SHELL_VERBOSITY=-1'); }, + static function () { putenv('SHELL_VERBOSITY'); }, + ]; + yield '$_ENV' => [ + static function () { $_ENV['SHELL_VERBOSITY'] = '-1'; }, + static function () { unset($_ENV['SHELL_VERBOSITY']); }, + ]; + yield '$_SERVER' => [ + static function () { $_SERVER['SHELL_VERBOSITY'] = '-1'; }, + static function () { unset($_SERVER['SHELL_VERBOSITY']); }, + ]; + } + public function testErrorOutput() { $application = new Application();