Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Redesign php artisan schedule:list Command. #41445

Merged
merged 17 commits into from Mar 12, 2022
Merged
139 changes: 121 additions & 18 deletions src/Illuminate/Console/Scheduling/ScheduleListCommand.php
Expand Up @@ -4,8 +4,10 @@

use Cron\CronExpression;
use DateTimeZone;
use Illuminate\Console\Application;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Console\Terminal;

class ScheduleListCommand extends Command
{
Expand All @@ -23,6 +25,13 @@ class ScheduleListCommand extends Command
*/
protected $description = 'List the scheduled commands';

/**
* The terminal width resolver callback.
*
* @var \Closure|null
*/
protected static $terminalWidthResolver;

/**
* Execute the console command.
*
Expand All @@ -33,25 +42,119 @@ class ScheduleListCommand extends Command
*/
public function handle(Schedule $schedule)
{
foreach ($schedule->events() as $event) {
$rows[] = [
$event->command,
$event->expression,
$event->description,
(new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
->format('Y-m-d H:i:s P'),
$event->mutex->exists($event) ? 'Yes' : '',
];
$events = collect($schedule->events());
$terminalWidth = $this->getTerminalWidth();
$expressionSpacing = $this->getCronExpressionSpacing($events);

$events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing) {
$expression = $this->formatCronExpression($event->expression, $expressionSpacing);

$command = $event->command;

if (! $this->output->isVerbose()) {
$command = str_replace(
Application::artisanBinary(),
preg_replace("#['\"]#", '', Application::artisanBinary()),
str_replace(Application::phpBinary(), 'php', $event->command)
);
}

$command = mb_strlen($command) > 1 ? "{$command} " : '';

$nextDueDateLabel = 'Next Due:';

$nextDueDate = Carbon::create((new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
);

$nextDueDate = $this->output->isVerbose()
? $nextDueDate->format('Y-m-d H:i:s P')
: $nextDueDate->diffForHumans();

$hasMutex = $event->mutex->exists($event) ? 'Has Mutex › ' : '';

$dots = str_repeat('.', max(
$terminalWidth - mb_strlen($expression.$command.$nextDueDateLabel.$nextDueDate.$hasMutex) - 8, 0
));

// Highlight the parameters...
$command = preg_replace("#(=['\"]?)([^'\"]+)(['\"]?)#", '$1<fg=yellow;options=bold>$2</>$3', $command);

return [sprintf(
' <fg=yellow>%s</> %s<fg=#6C7280>%s %s%s %s</>',
$expression,
$command,
$dots,
$hasMutex,
$nextDueDateLabel,
$nextDueDate
), $this->output->isVerbose() && mb_strlen($event->description) > 1 ? sprintf(
' <fg=#6C7280>%s%s %s</>',
str_repeat(' ', mb_strlen($expression) + 2),
'⇁',
$event->description
) : ''];
});

if ($events->isEmpty()) {
return $this->comment('No scheduled tasks have been defined.');
}

$this->table([
'Command',
'Interval',
'Description',
'Next Due',
'Has Mutex',
], $rows ?? []);
$this->output->writeln(
$events->flatten()->filter()->prepend('')->push('')->toArray()
);
}

/**
* Gets the spacing to be used on each event row.
*
* @param \Illuminate\Support\Collection $events
* @return array<int, int>
*/
private function getCronExpressionSpacing($events)
{
$rows = $events->map(fn ($event) => array_map('mb_strlen', explode(' ', $event->expression)));

return collect($rows[0] ?? [])->keys()->map(fn ($key) => $rows->max($key));
}

/**
* Formats the cron expression based on the spacing provided.
*
* @param string $expression
* @param array<int, int> $spacing
* @return string
*/
private function formatCronExpression($expression, $spacing)
{
$expression = explode(' ', $expression);

return collect($spacing)
->map(fn ($length, $index) => $expression[$index] = str_pad($expression[$index], $length))
->implode(' ');
}

/**
* Get the terminal width.
*
* @return int
*/
public static function getTerminalWidth()
{
return is_null(static::$terminalWidthResolver)
? (new Terminal)->getWidth()
: call_user_func(static::$terminalWidthResolver);
}

/**
* Set a callback that should be used when resolving the terminal width.
*
* @param \Closure|null $resolver
* @return void
*/
public static function resolveTerminalWidthUsing($resolver)
{
static::$terminalWidthResolver = $resolver;
}
}
64 changes: 64 additions & 0 deletions tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
@@ -0,0 +1,64 @@
<?php

namespace Illuminate\Tests\Integration\Console\Scheduling;

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\ScheduleListCommand;
use Illuminate\Support\Carbon;
use Illuminate\Support\ProcessUtils;
use Orchestra\Testbench\TestCase;

class ScheduleListCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

Carbon::setTestNow(now()->startOfYear());
ScheduleListCommand::resolveTerminalWidthUsing(fn () => 80);

$this->schedule = $this->app->make(Schedule::class);
}

public function testDisplaySchedule()
{
$this->schedule->call(fn () => '')->everyMinute();
$this->schedule->command(FooCommand::class)->quarterly();
$this->schedule->command('inspire')->twiceDaily(14, 18);
$this->schedule->command('foobar', ['a' => 'b'])->everyMinute();

$this->artisan(ScheduleListCommand::class)
->assertSuccessful()
->expectsOutput(' * * * * * ............................ Next Due: 1 minute from now')
->expectsOutput(' 0 0 1 1-12/3 * php artisan foo:command .... Next Due: 3 months from now')
->expectsOutput(' 0 14,18 * * * php artisan inspire ........ Next Due: 14 hours from now')
->expectsOutput(' * * * * * php artisan foobar a='.ProcessUtils::escapeArgument('b').' ... Next Due: 1 minute from now');
}

public function testDisplayScheduleInVerboseMode()
{
$this->schedule->command(FooCommand::class)->everyMinute();

$this->artisan(ScheduleListCommand::class, ['-v' => true])
->assertSuccessful()
->expectsOutputToContain('Next Due: '.now()->setMinutes(1)->format('Y-m-d H:i:s P'))
->expectsOutput(' ⇁ This is the description of the command.');
}

protected function tearDown(): void
{
parent::tearDown();

putenv('SHELL_VERBOSITY');

ScheduleListCommand::resolveTerminalWidthUsing(null);
}
}

class FooCommand extends Command
{
protected $signature = 'foo:command';

protected $description = 'This is the description of the command.';
}