Skip to content
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

## [Unreleased]

### Fixed

- **`ConsoleChannel` sent error-level lines to stdout when handed a command's output.**
`Illuminate\Console\Command::run()` wraps the terminal output in an `OutputStyle`, which is not a
`ConsoleOutputInterface`, so the `instanceof` check never found the error stream. The channel now
unwraps the style first, a local copy of `laranail/console`'s `ErrorOutput::unwrap()` since this
module carries no `laranail/*` dependency. Nothing in the package passes it an `OutputStyle`
today; an application that injected `$this->output` from a command did. `require` now names
`illuminate/console` for `OutputStyle`; `laravel/framework` already replaces it, so an
application installs nothing new.

### Changed

- `require` declares what `src/` uses directly: `illuminate/filesystem` and `illuminate/log`
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"php": "^8.3 || ^8.4 || ^8.5",
"illuminate/bus": "^13.0",
"illuminate/cache": "^13.0",
"illuminate/console": "^13.0",
"illuminate/contracts": "^13.0",
"illuminate/database": "^13.0",
"illuminate/filesystem": "^13.0",
Expand Down
13 changes: 11 additions & 2 deletions src/Channels/ConsoleChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Simtabi\Laranail\Notifications\Channels;

use Throwable;
use Illuminate\Console\OutputStyle;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Simtabi\Laranail\Notifications\DataTransferObjects\NotificationMessage;
Expand All @@ -16,6 +17,12 @@
* legacy adapter delegated to `laranail/console-tools`, which is removed here.
* Output goes to an injected {@see OutputInterface} when one is available,
* otherwise straight to STDOUT/STDERR via `fwrite` with a safe fallback.
*
* Error-level lines go to the error stream. A command's output is an {@see OutputStyle}, which
* `Illuminate\Console\Command::run()` wraps around the terminal's `ConsoleOutput` and which is not
* a {@see ConsoleOutputInterface}, so it is unwrapped first. This is a local copy of
* `laranail/console`'s `Tools\Support\ErrorOutput::unwrap()`, kept local because this module
* carries no `laranail/*` dependency.
*/
class ConsoleChannel extends AbstractNotificationChannel
{
Expand All @@ -39,8 +46,10 @@ public function send(NotificationMessage $message): bool

try {
if ($this->output !== null) {
if ($isError && $this->output instanceof ConsoleOutputInterface) {
$this->output->getErrorOutput()->writeln($line);
$console = $this->output instanceof OutputStyle ? $this->output->getOutput() : $this->output;

if ($isError && $console instanceof ConsoleOutputInterface) {
$console->getErrorOutput()->writeln($line);
} else {
$this->output->writeln($line);
}
Expand Down
93 changes: 93 additions & 0 deletions tests/Unit/ConsoleChannelStreamTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Notifications\Tests\Unit;

use LogicException;
use Illuminate\Console\OutputStyle;
use Symfony\Component\Console\Input\ArrayInput;
use Simtabi\Laranail\Notifications\Tests\TestCase;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Simtabi\Laranail\Notifications\Channels\ConsoleChannel;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Simtabi\Laranail\Notifications\DataTransferObjects\NotificationMessage;

/**
* A terminal stand-in: stdout and stderr are separate buffers, as they are for a real
* `ConsoleOutput`, so a test can tell which one a line went to.
*/
final class SplitBufferedOutput extends BufferedOutput implements ConsoleOutputInterface
{
private OutputInterface $stderr;

public function __construct()
{
parent::__construct();
$this->stderr = new BufferedOutput;
}

public function getErrorOutput(): OutputInterface
{
return $this->stderr;
}

public function setErrorOutput(OutputInterface $error): void
{
$this->stderr = $error;
}

public function section(): ConsoleSectionOutput
{
throw new LogicException('Sections are not used by these tests.');
}

public function stderr(): string
{
return $this->stderr instanceof BufferedOutput ? $this->stderr->fetch() : '';
}
}

/**
* Stream routing when the channel is handed a command's output.
*
* `Illuminate\Console\Command::run()` wraps the terminal output in an `OutputStyle`, which is not a
* `ConsoleOutputInterface`, so a bare `instanceof` check sends error-level lines to stdout.
*/
final class ConsoleChannelStreamTest extends TestCase
{
public function test_an_error_reaches_stderr_through_an_output_style(): void
{
$console = new SplitBufferedOutput;
$channel = new ConsoleChannel([], new OutputStyle(new ArrayInput([]), $console));

$this->assertTrue($channel->send(new NotificationMessage('disk full', level: 'error')));

$this->assertSame('', $console->fetch());
$this->assertStringContainsString('[ERROR] disk full', $console->stderr());
}

public function test_an_info_line_stays_on_stdout_through_an_output_style(): void
{
$console = new SplitBufferedOutput;
$channel = new ConsoleChannel([], new OutputStyle(new ArrayInput([]), $console));

$this->assertTrue($channel->send(new NotificationMessage('deployed', level: 'info')));

$this->assertStringContainsString('[INFO] deployed', $console->fetch());
$this->assertSame('', $console->stderr());
}

public function test_an_error_reaches_stderr_on_a_bare_console_output(): void
{
$console = new SplitBufferedOutput;
$channel = new ConsoleChannel([], $console);

$this->assertTrue($channel->send(new NotificationMessage('disk full', level: 'error')));

$this->assertSame('', $console->fetch());
$this->assertStringContainsString('[ERROR] disk full', $console->stderr());
}
}
Loading