From 1c110f96b2df370087b6b30a7afee9c118ad6481 Mon Sep 17 00:00:00 2001 From: Danny Foster Date: Wed, 10 Sep 2025 18:01:22 -0500 Subject: [PATCH 1/2] feat: add `--json` option to `schedule:list` --- .../Scheduling/ScheduleListCommand.php | 98 +++++++++++-- .../Scheduling/ScheduleListCommandTest.php | 132 ++++++++++++++++++ 2 files changed, 216 insertions(+), 14 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php index 0bb8f11ab498..4d93ec53715b 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php @@ -24,6 +24,7 @@ class ScheduleListCommand extends Command protected $signature = 'schedule:list {--timezone= : The timezone that times should be displayed in} {--next : Sort the listed tasks by their next due date} + {--json : Output the scheduled tasks as JSON} '; /** @@ -53,28 +54,20 @@ public function handle(Schedule $schedule) $events = new Collection($schedule->events()); if ($events->isEmpty()) { - $this->components->info('No scheduled tasks have been defined.'); + if ($this->option('json')) { + $this->output->writeln('[]'); + } else { + $this->components->info('No scheduled tasks have been defined.'); + } return; } - $terminalWidth = self::getTerminalWidth(); - - $expressionSpacing = $this->getCronExpressionSpacing($events); - - $repeatExpressionSpacing = $this->getRepeatExpressionSpacing($events); - $timezone = new DateTimeZone($this->option('timezone') ?? config('app.timezone')); $events = $this->sortEvents($events, $timezone); - $events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone) { - return $this->listEvent($event, $terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone); - }); - - $this->line( - $events->flatten()->filter()->prepend('')->push('')->toArray() - ); + $this->display($events, $timezone); } /** @@ -194,6 +187,83 @@ private function sortEvents(\Illuminate\Support\Collection $events, DateTimeZone : $events; } + /** + * Render the scheduled tasks information. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return void + */ + protected function display(Collection $events, DateTimeZone $timezone) + { + $this->option('json') ? $this->displayJson($events, $timezone) : $this->displayForCli($events, $timezone); + } + + /** + * Render the scheduled tasks information as JSON. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return void + */ + protected function displayJson(Collection $events, DateTimeZone $timezone) + { + $data = $events->map(function ($event) use ($timezone) { + $nextDueDate = $this->getNextDueDateForEvent($event, $timezone); + + $command = $event->command ?? ''; + + if (! $this->output->isVerbose()) { + $command = $event->normalizeCommand($command); + } + + if ($event instanceof CallbackEvent) { + $command = $event->getSummaryForDisplay(); + + if (in_array($command, ['Closure', 'Callback'])) { + $command = 'Closure at: '.$this->getClosureLocation($event); + } + } + + return [ + 'expression' => $event->expression, + 'repeat_seconds' => $event->isRepeatable() ? $event->repeatSeconds : null, + 'command' => $command, + 'description' => $event->description ?? null, + 'next_due_date' => $nextDueDate->format('Y-m-d H:i:s P'), + 'next_due_date_human' => $nextDueDate->diffForHumans(), + 'timezone' => $timezone->getName(), + 'has_mutex' => $event->mutex->exists($event), + ]; + })->values(); + + $this->output->writeln($data->toJson()); + } + + /** + * Render the scheduled tasks information formatted for the CLI. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return void + */ + protected function displayForCli(Collection $events, DateTimeZone $timezone) + { + $terminalWidth = self::getTerminalWidth(); + + $expressionSpacing = $this->getCronExpressionSpacing($events); + + $repeatExpressionSpacing = $this->getRepeatExpressionSpacing($events); + + $events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone) { + return $this->listEvent($event, $terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone); + }); + + $this->line( + $events->flatten()->filter()->prepend('')->push('')->toArray() + ); + } + /** * Get the next due date for an event. * diff --git a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php index b65ab28d218e..633288e1188a 100644 --- a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Console\Scheduling; +use Illuminate\Console\Application; use Illuminate\Console\Command; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\ScheduleListCommand; @@ -31,6 +32,15 @@ public function testDisplayEmptySchedule() ->expectsOutputToContain('No scheduled tasks have been defined.'); } + public function testDisplayEmptyScheduleAsJson() + { + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, ['--json' => true]); + $output = Artisan::output(); + + $this->assertJson($output); + $this->assertJsonStringEqualsJsonString('[]', $output); + } + public function testDisplaySchedule() { $this->schedule->command(FooCommand::class)->quarterly(); @@ -65,6 +75,128 @@ public function testDisplaySchedule() ->expectsOutput(' * * * * * Closure at: '.$closureFilePath.':'.$closureLineNumber.' Next Due: 1 minute from now'); } + public function testDisplayScheduleAsJson() + { + $this->schedule->command(FooCommand::class)->quarterly(); + $this->schedule->command('inspire')->twiceDaily(14, 18); + $this->schedule->command('foobar', ['a' => 'b'])->everyMinute(); + $this->schedule->job(FooJob::class)->everyMinute(); + $this->schedule->job(new FooParamJob('test'))->everyMinute(); + $this->schedule->job(FooJob::class)->name('foo-named-job')->everyMinute(); + $this->schedule->job(new FooParamJob('test'))->name('foo-named-param-job')->everyMinute(); + $this->schedule->command('inspire')->cron('0 9,17 * * *'); + $this->schedule->call(fn () => '')->everyMinute(); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, ['--json' => true]); + $output = Artisan::output(); + + $this->assertJson($output); + $data = json_decode($output, true); + + $this->assertIsArray($data); + $this->assertCount(9, $data); + + $this->assertSame('0 0 1 1-12/3 *', $data[0]['expression']); + $this->assertNull($data[0]['repeat_seconds']); + $this->assertSame('php artisan foo:command', $data[0]['command']); + $this->assertSame('This is the description of the command.', $data[0]['description']); + $this->assertStringContainsString('2023-04-01 00:00:00', $data[0]['next_due_date']); + $this->assertSame('3 months from now', $data[0]['next_due_date_human']); + $this->assertFalse($data[0]['has_mutex']); + + $this->assertSame('* * * * *', $data[2]['expression']); + $this->assertSame('php artisan foobar a='.ProcessUtils::escapeArgument('b'), $data[2]['command']); + $this->assertNull($data[2]['description']); + $this->assertSame('1 minute from now', $data[2]['next_due_date_human']); + + $this->assertSame('Illuminate\Tests\Integration\Console\Scheduling\FooJob', $data[3]['command']); + + $this->assertSame('foo-named-job', $data[5]['command']); + + $this->assertStringContainsString('Closure at:', $data[8]['command']); + $this->assertStringContainsString('ScheduleListCommandTest.php', $data[8]['command']); + } + + public function testDisplayScheduleWithSortAsJson() + { + $this->schedule->command(FooCommand::class)->quarterly(); + $this->schedule->command('inspire')->twiceDaily(14, 18); + $this->schedule->command('foobar', ['a' => 'b'])->everyMinute(); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, [ + '--next' => true, + '--json' => true, + ]); + $output = Artisan::output(); + + $this->assertJson($output); + $data = json_decode($output, true); + + $this->assertIsArray($data); + $this->assertCount(3, $data); + + $this->assertSame('* * * * *', $data[0]['expression']); + $this->assertSame('1 minute from now', $data[0]['next_due_date_human']); + $this->assertSame('php artisan foobar a='.ProcessUtils::escapeArgument('b'), $data[0]['command']); + + $this->assertSame('0 14,18 * * *', $data[1]['expression']); + $this->assertSame('14 hours from now', $data[1]['next_due_date_human']); + $this->assertSame('php artisan inspire', $data[1]['command']); + + $this->assertSame('0 0 1 1-12/3 *', $data[2]['expression']); + $this->assertSame('3 months from now', $data[2]['next_due_date_human']); + $this->assertSame('php artisan foo:command', $data[2]['command']); + } + + public function testDisplayScheduleAsJsonWithTimezone() + { + $this->schedule->command('inspire')->daily(); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, [ + '--timezone' => 'America/Chicago', + '--json' => true, + ]); + $output = Artisan::output(); + + $this->assertJson($output); + $data = json_decode($output, true); + + $this->assertIsArray($data); + $this->assertCount(1, $data); + $this->assertSame('America/Chicago', $data[0]['timezone']); + $this->assertStringContainsString('-06:00', $data[0]['next_due_date']); + $this->assertSame('php artisan inspire', $data[0]['command']); + } + + public function testDisplayScheduleAsJsonInVerboseMode() + { + $this->schedule->command(FooCommand::class)->quarterly(); + $this->schedule->command('inspire')->everyMinute(); + $this->schedule->call(fn () => '')->everyMinute(); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, [ + '--json' => true, + '-v' => true, + ]); + $output = Artisan::output(); + + $this->assertJson($output); + $data = json_decode($output, true); + + $this->assertIsArray($data); + $this->assertCount(3, $data); + + $this->assertSame('0 0 1 1-12/3 *', $data[0]['expression']); + $this->assertSame(Application::phpBinary().' '.Application::artisanBinary().' foo:command', $data[0]['command']); + $this->assertSame('This is the description of the command.', $data[0]['description']); + + $this->assertSame('* * * * *', $data[1]['expression']); + $this->assertSame(Application::phpBinary().' '.Application::artisanBinary().' inspire', $data[1]['command']); + + $this->assertStringContainsString('Closure at:', $data[2]['command']); + $this->assertStringContainsString('ScheduleListCommandTest.php', $data[2]['command']); + } + public function testDisplayScheduleWithSort() { $this->schedule->command(FooCommand::class)->quarterly(); From 20fc7788b9ecfedb7262e8fb8b3496f18adbd8f1 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 12 Sep 2025 10:28:48 -0500 Subject: [PATCH 2/2] formatting --- .../Scheduling/ScheduleListCommand.php | 132 +++++++++--------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php index 4d93ec53715b..8ba7ce997aa9 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php @@ -67,7 +67,72 @@ public function handle(Schedule $schedule) $events = $this->sortEvents($events, $timezone); - $this->display($events, $timezone); + $this->option('json') + ? $this->displayJson($events, $timezone) + : $this->displayForCli($events, $timezone); + } + + /** + * Render the scheduled tasks information as JSON. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return void + */ + protected function displayJson(Collection $events, DateTimeZone $timezone) + { + $this->output->writeln($events->map(function ($event) use ($timezone) { + $nextDueDate = $this->getNextDueDateForEvent($event, $timezone); + + $command = $event->command ?? ''; + + if (! $this->output->isVerbose()) { + $command = $event->normalizeCommand($command); + } + + if ($event instanceof CallbackEvent) { + $command = $event->getSummaryForDisplay(); + + if (in_array($command, ['Closure', 'Callback'])) { + $command = 'Closure at: '.$this->getClosureLocation($event); + } + } + + return [ + 'expression' => $event->expression, + 'command' => $command, + 'description' => $event->description ?? null, + 'next_due_date' => $nextDueDate->format('Y-m-d H:i:s P'), + 'next_due_date_human' => $nextDueDate->diffForHumans(), + 'timezone' => $timezone->getName(), + 'has_mutex' => $event->mutex->exists($event), + 'repeat_seconds' => $event->isRepeatable() ? $event->repeatSeconds : null, + ]; + })->values()->toJson()); + } + + /** + * Render the scheduled tasks information formatted for the CLI. + * + * @param \Illuminate\Support\Collection $events + * @param \DateTimeZone $timezone + * @return void + */ + protected function displayForCli(Collection $events, DateTimeZone $timezone) + { + $terminalWidth = self::getTerminalWidth(); + + $expressionSpacing = $this->getCronExpressionSpacing($events); + + $repeatExpressionSpacing = $this->getRepeatExpressionSpacing($events); + + $events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone) { + return $this->listEvent($event, $terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone); + }); + + $this->line( + $events->flatten()->filter()->prepend('')->push('')->toArray() + ); } /** @@ -199,71 +264,6 @@ protected function display(Collection $events, DateTimeZone $timezone) $this->option('json') ? $this->displayJson($events, $timezone) : $this->displayForCli($events, $timezone); } - /** - * Render the scheduled tasks information as JSON. - * - * @param \Illuminate\Support\Collection $events - * @param \DateTimeZone $timezone - * @return void - */ - protected function displayJson(Collection $events, DateTimeZone $timezone) - { - $data = $events->map(function ($event) use ($timezone) { - $nextDueDate = $this->getNextDueDateForEvent($event, $timezone); - - $command = $event->command ?? ''; - - if (! $this->output->isVerbose()) { - $command = $event->normalizeCommand($command); - } - - if ($event instanceof CallbackEvent) { - $command = $event->getSummaryForDisplay(); - - if (in_array($command, ['Closure', 'Callback'])) { - $command = 'Closure at: '.$this->getClosureLocation($event); - } - } - - return [ - 'expression' => $event->expression, - 'repeat_seconds' => $event->isRepeatable() ? $event->repeatSeconds : null, - 'command' => $command, - 'description' => $event->description ?? null, - 'next_due_date' => $nextDueDate->format('Y-m-d H:i:s P'), - 'next_due_date_human' => $nextDueDate->diffForHumans(), - 'timezone' => $timezone->getName(), - 'has_mutex' => $event->mutex->exists($event), - ]; - })->values(); - - $this->output->writeln($data->toJson()); - } - - /** - * Render the scheduled tasks information formatted for the CLI. - * - * @param \Illuminate\Support\Collection $events - * @param \DateTimeZone $timezone - * @return void - */ - protected function displayForCli(Collection $events, DateTimeZone $timezone) - { - $terminalWidth = self::getTerminalWidth(); - - $expressionSpacing = $this->getCronExpressionSpacing($events); - - $repeatExpressionSpacing = $this->getRepeatExpressionSpacing($events); - - $events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone) { - return $this->listEvent($event, $terminalWidth, $expressionSpacing, $repeatExpressionSpacing, $timezone); - }); - - $this->line( - $events->flatten()->filter()->prepend('')->push('')->toArray() - ); - } - /** * Get the next due date for an event. *