From 8123a3ff1ed23d04123fbe01da595efd0f0476ca Mon Sep 17 00:00:00 2001 From: bert-w Date: Sat, 11 Mar 2023 01:17:13 +0100 Subject: [PATCH 1/3] Proper input argument parsing --- src/Illuminate/Console/Application.php | 9 +- .../Integration/Console/CommandEventsTest.php | 252 ++++++++++++++++++ 2 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Console/CommandEventsTest.php diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 78a8c0468d74..e846197013f0 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -87,19 +87,22 @@ public function __construct(Container $laravel, Dispatcher $events, $version) * * @return int */ - public function run(InputInterface $input = null, OutputInterface $output = null): int + protected function doRunCommand(SymfonyCommand $command, InputInterface $input, OutputInterface $output) { $commandName = $this->getCommandName( $input = $input ?: new ArgvInput ); + $command->mergeApplicationDefinition(); + $this->events->dispatch( new CommandStarting( - $commandName, $input, $output = $output ?: new BufferedConsoleOutput + $commandName, + tap($input)->bind($command->getDefinition()), $output = $output ?: new BufferedConsoleOutput ) ); - $exitCode = parent::run($input, $output); + $exitCode = parent::doRunCommand($command, $input, $output); $this->events->dispatch( new CommandFinished($commandName, $input, $output, $exitCode) diff --git a/tests/Integration/Console/CommandEventsTest.php b/tests/Integration/Console/CommandEventsTest.php new file mode 100644 index 000000000000..1bfced2b4830 --- /dev/null +++ b/tests/Integration/Console/CommandEventsTest.php @@ -0,0 +1,252 @@ +fs = new Filesystem; + + $this->id = Str::random(); + $this->logfile = storage_path("logs/command_events_test_{$this->id}.log"); + + $this->writeArtisanScript(); + } + + protected function tearDown(): void + { + $this->fs->delete($this->logfile); + $this->fs->delete(base_path('artisan')); + + if (!is_null($this->originalArtisan)) { + $this->fs->put(base_path('artisan'), $this->originalArtisan); + } + + parent::tearDown(); + } + + /** + * @dataProvider commandEventsProvider + */ + public function testCommandEventsReceiveParsedInput($processType, $argumentType) + { + switch ($processType) { + case 'foreground': + $this->app[\Illuminate\Contracts\Console\Kernel::class]->registerCommand(new CommandEventsTestCommand); + $this->app[Dispatcher::class]->listen(function (CommandStarting $event) { + array_map(fn ($e) => $this->fs->append($this->logfile, $e."\n"), [ + 'CommandStarting', + $event->input->getArgument('firstname'), + $event->input->getArgument('lastname'), + $event->input->getOption('occupation'), + ]); + }); + + Event::listen(function (CommandFinished $event) { + array_map(fn ($e) => $this->fs->append($this->logfile, $e."\n"), [ + 'CommandFinished', + $event->input->getArgument('firstname'), + $event->input->getArgument('lastname'), + $event->input->getOption('occupation'), + ]); + }); + switch($argumentType) { + case 'array': + $this->artisan(CommandEventsTestCommand::class, [ + 'firstname' => 'taylor', + 'lastname' => 'otwell', + '--occupation' => 'coding', + ]); + break; + case 'string': + $this->artisan('command-events-test-command taylor otwell --occupation=coding'); + break; + } + break; + case 'background': + // Initialize empty logfile. + $this->fs->append($this->logfile, ''); + exec('php '.base_path('artisan').' command-events-test-command-'.$this->id.' taylor otwell --occupation=coding'); + // Since our command is running in a separate process, we need to wait + // until it has finished executing before running our assertions. + $this->waitForLogMessages( + 'CommandStarting', 'taylor', 'otwell', 'coding', + 'CommandFinished', 'taylor', 'otwell', 'coding', + ); + break; + } + + $this->assertLogged( + 'CommandStarting', 'taylor', 'otwell', 'coding', + 'CommandFinished', 'taylor', 'otwell', 'coding', + ); + } + + public static function commandEventsProvider() + { + return [ + 'Foreground with array' => ['foreground', 'array'], + 'Foreground with string' => ['foreground', 'string'], + 'Background' => ['background', 'string'], + ]; + } + + protected function waitForLogMessages(...$messages) + { + $tries = 0; + $sleep = 100000; // 100K microseconds = 0.1 second + $limit = 50; // 0.1s * 50 = 5 second wait limit + + do { + $log = $this->fs->get($this->logfile); + + if (Str::containsAll($log, $messages)) { + return; + } + + $tries++; + usleep($sleep); + } while ($tries < $limit); + } + + protected function assertLogged(...$messages) + { + $log = trim($this->fs->get($this->logfile)); + + $this->assertEquals(implode("\n", $messages), $log); + } + + protected function writeArtisanScript() + { + $path = base_path('artisan'); + + // Save existing artisan script if there is one + if ($this->fs->exists($path)) { + $this->originalArtisan = $this->fs->get($path); + } + + $thisFile = __FILE__; + $logfile = var_export($this->logfile, true); + + $script = <<make(Illuminate\Contracts\Console\Kernel::class); + +class CommandEventsTestCommand extends Illuminate\Console\Command +{ + protected \$signature = 'command-events-test-command-{$this->id} {firstname} {lastname} {--occupation=cook}'; + + public function handle() + { + // ... + } +} + +// Register command with Kernel +Illuminate\Console\Application::starting(function (\$artisan) { + \$artisan->add(new CommandEventsTestCommand); +}); + +// Add command to scheduler so that the after() callback is trigger in our spawned process +Illuminate\Foundation\Application::getInstance() + ->booted(function (\$app) { + \$fs = new Illuminate\Filesystem\Filesystem; + \$log = fn (\$msg) => \$fs->append({$logfile}, \$msg."\\n"); + + \$app[\Illuminate\Events\Dispatcher::class]->listen(function (\Illuminate\Console\Events\CommandStarting \$event) use (\$log) { + array_map(fn (\$msg) => \$log(\$msg), [ + 'CommandStarting', + \$event->input->getArgument('firstname'), + \$event->input->getArgument('lastname'), + \$event->input->getOption('occupation'), + ]); + }); + + \$app[\Illuminate\Events\Dispatcher::class]->listen(function (\Illuminate\Console\Events\CommandFinished \$event) use (\$log) { + array_map(fn (\$msg) => \$log(\$msg), [ + 'CommandFinished', + \$event->input->getArgument('firstname'), + \$event->input->getArgument('lastname'), + \$event->input->getOption('occupation'), + ]); + }); + }); + +\$status = \$kernel->handle( + \$input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +\$kernel->terminate(\$input, \$status); + +exit(\$status); + +PHP; + + $this->fs->put($path, $script); + } +} + +class CommandEventsTestCommand extends \Illuminate\Console\Command +{ + protected $signature = 'command-events-test-command {firstname} {lastname} {--occupation=cook}'; + + public function handle() + { + // ... + } +} From 79273f31daa58a03c749dffe2a76cdc43231c145 Mon Sep 17 00:00:00 2001 From: bert-w Date: Sat, 11 Mar 2023 01:31:52 +0100 Subject: [PATCH 2/3] styleci --- tests/Integration/Console/CommandEventsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Console/CommandEventsTest.php b/tests/Integration/Console/CommandEventsTest.php index 1bfced2b4830..ad755d5a1a00 100644 --- a/tests/Integration/Console/CommandEventsTest.php +++ b/tests/Integration/Console/CommandEventsTest.php @@ -58,7 +58,7 @@ protected function tearDown(): void $this->fs->delete($this->logfile); $this->fs->delete(base_path('artisan')); - if (!is_null($this->originalArtisan)) { + if (! is_null($this->originalArtisan)) { $this->fs->put(base_path('artisan'), $this->originalArtisan); } @@ -90,7 +90,7 @@ public function testCommandEventsReceiveParsedInput($processType, $argumentType) $event->input->getOption('occupation'), ]); }); - switch($argumentType) { + switch ($argumentType) { case 'array': $this->artisan(CommandEventsTestCommand::class, [ 'firstname' => 'taylor', From 5d3586e37f35b99e455efa8b4735e588be3a6fdd Mon Sep 17 00:00:00 2001 From: bert-w Date: Sat, 11 Mar 2023 02:09:16 +0100 Subject: [PATCH 3/3] ignore errors since commands may choose to ignore input validation --- src/Illuminate/Console/Application.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index e846197013f0..a416df9ed233 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Exception\CommandNotFoundException; +use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; @@ -93,13 +94,14 @@ protected function doRunCommand(SymfonyCommand $command, InputInterface $input, $input = $input ?: new ArgvInput ); - $command->mergeApplicationDefinition(); + try { + $input->bind(tap($command)->mergeApplicationDefinition()->getDefinition()); + } catch (ExceptionInterface) { + // ... + } $this->events->dispatch( - new CommandStarting( - $commandName, - tap($input)->bind($command->getDefinition()), $output = $output ?: new BufferedConsoleOutput - ) + new CommandStarting($commandName, $input, $output = $output ?: new BufferedConsoleOutput) ); $exitCode = parent::doRunCommand($command, $input, $output);