diff --git a/example/01-run-due-jobs.php b/example/01-run-due-jobs.php new file mode 100644 index 0000000..9723072 --- /dev/null +++ b/example/01-run-due-jobs.php @@ -0,0 +1,29 @@ +format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +$queue = new Queue($now); +$crontabParser = new CrontabParser(new ExpressionFactory()); +$jobRepository = new JobRepository(ScriptOutputMode::INHERIT); +$crontabParser->parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Command output:" . PHP_EOL; +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Jobs ran: " . count($runCommandList) . PHP_EOL; diff --git a/example/02-next-job.php b/example/02-next-job.php new file mode 100644 index 0000000..c52e9e5 --- /dev/null +++ b/example/02-next-job.php @@ -0,0 +1,31 @@ +parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +echo "Command output:" . PHP_EOL; +$queue->runDueJobsAndGetCommands(); + +echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL; +echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL; diff --git a/example/03-nickname-expressions.php b/example/03-nickname-expressions.php new file mode 100644 index 0000000..efdf89e --- /dev/null +++ b/example/03-nickname-expressions.php @@ -0,0 +1,31 @@ +parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +echo "Command output:" . PHP_EOL; +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Commands due right now: " . implode(", ", $runCommandList) . PHP_EOL; +echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL; +echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL; diff --git a/example/04-custom-expression-factory.php b/example/04-custom-expression-factory.php new file mode 100644 index 0000000..90f4e26 --- /dev/null +++ b/example/04-custom-expression-factory.php @@ -0,0 +1,49 @@ +format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; +echo "This example injects a custom ExpressionFactory to handle @start." . PHP_EOL; +echo "Command output:" . PHP_EOL; + +$queue = new Queue($now); +$jobRepository = new JobRepository(ScriptOutputMode::INHERIT); +(new CrontabParser($customExpressionFactory)) + ->parseIntoQueue($crontab, $queue, $jobRepository); + +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Jobs ran: " . count($runCommandList) . PHP_EOL; diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..af97866 --- /dev/null +++ b/example/README.md @@ -0,0 +1,12 @@ +# Examples + +Run each example from the project root with `php`: + +```bash +php example/01-run-due-jobs.php +php example/02-next-job.php +php example/03-nickname-expressions.php +php example/04-custom-expression-factory.php +``` + +Each script embeds its own crontab string so you can read the schedule and the code together. diff --git a/src/FunctionCommand.php b/src/FunctionCommand.php new file mode 100644 index 0000000..ea1c972 --- /dev/null +++ b/src/FunctionCommand.php @@ -0,0 +1,40 @@ +callableName($command); + return str_contains($callable, "::") || is_callable($callable); + } + + public function execute(string $command):void { + $callableName = $this->callableName($command); + $callable = explode("::", $callableName); + if(!is_callable($callable)) { + throw new FunctionExecutionException($callableName); + } + + call_user_func_array($callable, $this->arguments($command)); + } + + protected function callableName(string $command):string { + $bracketPos = strpos($command, "("); + if($bracketPos === false) { + return trim($command); + } + + return trim(substr($command, 0, $bracketPos)); + } + + /** @return array */ + protected function arguments(string $command):array { + $bracketPos = strpos($command, "("); + if($bracketPos === false) { + return []; + } + + $argsString = substr($command, $bracketPos); + $argsString = trim($argsString, " ();"); + return str_getcsv($argsString); + } +} diff --git a/src/Job.php b/src/Job.php index 2b6ec94..e486d6f 100644 --- a/src/Job.php +++ b/src/Job.php @@ -4,26 +4,28 @@ use DateTime; class Job { - protected ScriptCommandResolver $scriptCommandResolver; protected Expression $expression; protected string $command; protected bool $hasRun; - protected ScriptOutputMode $scriptOutputMode; protected string $stdout; protected string $stderr; + protected FunctionCommand $functionCommand; + protected ScriptRunner $scriptRunner; public function __construct( Expression $expression, string $command, - ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD + ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD, + ?FunctionCommand $functionCommand = null, + ?ScriptRunner $scriptRunner = null, ) { - $this->scriptCommandResolver = new ScriptCommandResolver(); $this->expression = $expression; $this->command = $command; $this->hasRun = false; - $this->scriptOutputMode = $scriptOutputMode; $this->stdout = ""; $this->stderr = ""; + $this->functionCommand = $functionCommand ?? new FunctionCommand(); + $this->scriptRunner = $scriptRunner ?? new ScriptRunner($scriptOutputMode); } public function isDue(?DateTime $now = null):bool { @@ -58,13 +60,14 @@ public function run():void { $this->stdout = ""; $this->stderr = ""; - if($this->isFunction()) { - $this->executeFunction(); - } - else { - // Assume the command is a shell command. - $this->executeScript(); + if($this->functionCommand->isCallable($this->command)) { + $this->functionCommand->execute($this->command); + return; } + + $scriptResult = $this->scriptRunner->run($this->command); + $this->stdout = $scriptResult->stdout; + $this->stderr = $scriptResult->stderr; } public function hasRun():bool { @@ -76,139 +79,6 @@ public function resetRunFlag():void { } public function isFunction():bool { - $command = $this->command; - $bracketPos = strpos( - $command, - "(" - ); - if($bracketPos !== false) { - $command = substr($command, 0, $bracketPos); - $command = trim($command); - } - - return strstr($command, "::") - || is_callable($command); - } - - protected function executeFunction():void { - $command = $this->command; - $args = []; - $bracketPos = strpos($command, "("); - if($bracketPos !== false) { - $argsString = substr( - $command, - $bracketPos - ); - $argsString = trim($argsString, " ();"); - $args = str_getcsv($argsString, ",", "\"", "\\"); - - $command = substr( - $command, - 0, - $bracketPos - ); - $command = trim($command); - } - - $callable = explode("::", $command); - - if(!is_callable($callable)) { - throw new FunctionExecutionException($command); - } - call_user_func_array($callable, $args); - } - - protected function executeScript():void { - $command = $this->scriptCommandResolver->resolve($this->command); - $descriptor = $this->createScriptDescriptor(); - $pipes = []; - - $proc = proc_open( - $command, - $descriptor, - $pipes - ); - - do { - if($proc) { - $status = proc_get_status($proc); - } - else { - $status = [ - "running" => false, - "exitcode" => -1, - ]; - } - }while($status["running"]); - - if($proc) { - $this->captureProcessOutput($pipes); - } - - if($status["exitcode"] > 0) { - throw new ScriptExecutionException( - $this->command - ); - } - - if($proc) { - $this->closePipes($pipes); - proc_close($proc); - } - } - - /** @return array */ - protected function createScriptDescriptor():array { - $stdin = ["pipe", "r"]; - - return match($this->scriptOutputMode) { - ScriptOutputMode::INHERIT => [ - 0 => $stdin, - 1 => ["file", "php://stdout", "w"], - 2 => ["file", "php://stderr", "w"], - ], - ScriptOutputMode::CAPTURE => [ - 0 => $stdin, - 1 => ["pipe", "w"], - 2 => ["pipe", "w"], - ], - default => [ - 0 => $stdin, - 1 => ["file", $this->nullDevice(), "w"], - 2 => ["file", $this->nullDevice(), "w"], - ], - }; - } - - /** @param array $pipes */ - protected function captureProcessOutput(array $pipes):void { - if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) { - return; - } - - if(isset($pipes[1]) && is_resource($pipes[1])) { - $this->stdout = stream_get_contents($pipes[1]) ?: ""; - } - - if(isset($pipes[2]) && is_resource($pipes[2])) { - $this->stderr = stream_get_contents($pipes[2]) ?: ""; - } - } - - /** @param array $pipes */ - protected function closePipes(array $pipes):void { - foreach($pipes as $pipe) { - if(is_resource($pipe)) { - fclose($pipe); - } - } - } - - protected function nullDevice():string { - if(PHP_OS_FAMILY === "Windows") { - return "NUL"; - } - - return "/dev/null"; + return $this->functionCommand->isCallable($this->command); } } diff --git a/src/ResolvedScriptCommand.php b/src/ResolvedScriptCommand.php new file mode 100644 index 0000000..cdaf127 --- /dev/null +++ b/src/ResolvedScriptCommand.php @@ -0,0 +1,48 @@ +\\S+)(?P\\s.*)?$/", $command, $matches)) { + return $command; + } + + $script = $this->normaliseScriptName($matches["script"]); + if(!$this->isValidScriptName($script)) { + return $command; + } + + $scriptPath = implode(DIRECTORY_SEPARATOR, [ + getcwd(), + "cron", + "$script.php", + ]); + if(!is_file($scriptPath)) { + return $command; + } + + return PHP_BINARY + . " " + . escapeshellarg($scriptPath) + . ($matches["args"] ?? ""); + } + + protected function normaliseScriptName(string $script):string { + if(substr(strtolower($script), -4) === ".php") { + return substr($script, 0, -4); + } + + return $script; + } + + protected function isValidScriptName(string $script):bool { + if(str_contains($script, "/") + || str_contains($script, "\\")) { + return false; + } + + return strlen($script) > 0 + && preg_match("/^[a-zA-Z0-9._-]+$/", $script); + } +} diff --git a/src/ScriptResult.php b/src/ScriptResult.php new file mode 100644 index 0000000..6eebe43 --- /dev/null +++ b/src/ScriptResult.php @@ -0,0 +1,10 @@ +resolvedScriptCommand = $resolvedScriptCommand ?? new ResolvedScriptCommand(); + } + + protected ResolvedScriptCommand $resolvedScriptCommand; + + public function run(string $command):ScriptResult { + $resolvedCommand = $this->resolvedScriptCommand->resolve($command); + $descriptor = $this->createDescriptor(); + $pipes = []; + $proc = proc_open($resolvedCommand, $descriptor, $pipes); + if($proc === false) { + throw new ScriptExecutionException($command); + } + + $status = $this->waitForExit($proc); + $result = $this->captureOutput($pipes); + + if($status["exitcode"] > 0) { + throw new ScriptExecutionException($command); + } + + $this->closePipes($pipes); + proc_close($proc); + + return $result; + } + + /** @return array */ + protected function createDescriptor():array { + $stdin = ["pipe", "r"]; + + return match($this->scriptOutputMode) { + ScriptOutputMode::INHERIT => [ + 0 => $stdin, + 1 => ["file", "php://stdout", "w"], + 2 => ["file", "php://stderr", "w"], + ], + ScriptOutputMode::CAPTURE => [ + 0 => $stdin, + 1 => ["pipe", "w"], + 2 => ["pipe", "w"], + ], + default => [ + 0 => $stdin, + 1 => ["file", $this->nullDevice(), "w"], + 2 => ["file", $this->nullDevice(), "w"], + ], + }; + } + + /** + * @param mixed $proc + * @return array{running:bool,exitcode:int} + */ + protected function waitForExit($proc):array { + do { + $status = proc_get_status($proc); + } while($status["running"]); + + return [ + "running" => (bool)$status["running"], + "exitcode" => (int)$status["exitcode"], + ]; + } + + /** @param array $pipes */ + protected function captureOutput(array $pipes):ScriptResult { + if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) { + return new ScriptResult(); + } + + $stdout = ""; + if(isset($pipes[1]) && is_resource($pipes[1])) { + $stdout = stream_get_contents($pipes[1]) ?: ""; + } + + $stderr = ""; + if(isset($pipes[2]) && is_resource($pipes[2])) { + $stderr = stream_get_contents($pipes[2]) ?: ""; + } + + return new ScriptResult($stdout, $stderr); + } + + /** @param array $pipes */ + protected function closePipes(array $pipes):void { + foreach($pipes as $pipe) { + if(is_resource($pipe)) { + fclose($pipe); + } + } + } + + protected function nullDevice():string { + if(PHP_OS_FAMILY === "Windows") { + return "NUL"; + } + + return "/dev/null"; + } +} diff --git a/test/phpunit/CronExpressionTest.php b/test/phpunit/CronExpressionTest.php index 52a0521..e2c8960 100644 --- a/test/phpunit/CronExpressionTest.php +++ b/test/phpunit/CronExpressionTest.php @@ -7,36 +7,49 @@ use PHPUnit\Framework\TestCase; class CronExpressionTest extends TestCase { - public function testNicknameIsExpanded():void { - $expression = new CronExpression("@hourly"); - $due = new DateTime("2026-03-16 10:00:00"); - $notDue = new DateTime("2026-03-16 10:15:00"); + public function testIsDueEveryMinute():void { + $expression = new CronExpression("* * * * *"); + self::assertTrue($expression->isDue(new DateTime("2026-03-11 12:34:56"))); + } + + public function testGetNextRunDateSkipsCurrentMinute():void { + $expression = new CronExpression("* * * * *"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:56")); + self::assertSame("2026-03-11 12:35:00", $nextRunDate->format("Y-m-d H:i:s")); + } - self::assertTrue($expression->isDue($due)); - self::assertFalse($expression->isDue($notDue)); + public function testStepRangeAndListSyntax():void { + $expression = new CronExpression("*/15 9-17 * * 1,3,5"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-13 09:30:20"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-13 09:31:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-14 09:30:00"))); } - public function testWeekdaySevenNormalisesToSunday():void { - $expression = new CronExpression("0 0 * * 7"); - $sunday = new DateTime("2026-03-15 00:00:00"); - $monday = new DateTime("2026-03-16 00:00:00"); + public function testMonthAndWeekdayNames():void { + $expression = new CronExpression("0 22 * JAN MON-FRI"); - self::assertTrue($expression->isDue($sunday)); - self::assertFalse($expression->isDue($monday)); + self::assertTrue($expression->isDue(new DateTime("2027-01-04 22:00:10"))); + self::assertFalse($expression->isDue(new DateTime("2027-02-04 22:00:00"))); + self::assertFalse($expression->isDue(new DateTime("2027-01-03 22:00:00"))); } - public function testNextRunDateSupportsStepValues():void { - $expression = new CronExpression("*/15 * * * *"); - $now = new DateTime("2026-03-16 10:07:42"); + public function testDayOfMonthAndDayOfWeekUseCronOrSemantics():void { + $expression = new CronExpression("0 12 13 * FRI"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-13 12:00:00"))); + self::assertTrue($expression->isDue(new DateTime("2026-11-06 12:00:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-12 12:00:00"))); + } - self::assertSame( - "2026-03-16 10:15:00", - $expression->getNextRunDate($now)->format("Y-m-d H:i:s") - ); + public function testNicknameExpansion():void { + $expression = new CronExpression("@daily"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:00")); + self::assertSame("2026-03-12 00:00:00", $nextRunDate->format("Y-m-d H:i:s")); } - public function testInvalidStepThrowsException():void { + public function testInvalidFieldThrows():void { self::expectException(InvalidArgumentException::class); - new CronExpression("*/0 * * * *"); + new CronExpression("* * * ABC *"); } } diff --git a/test/phpunit/FunctionCommandTest.php b/test/phpunit/FunctionCommandTest.php new file mode 100644 index 0000000..9a9d6ba --- /dev/null +++ b/test/phpunit/FunctionCommandTest.php @@ -0,0 +1,50 @@ +isCallable( + "Gt\\Cron\\Test\\Helper\\ExampleClass::doSomething" + )); + } + + public function testIsCallableReturnsFalseForShellCommand():void { + $command = new FunctionCommand(); + + self::assertFalse($command->isCallable("php -v")); + } + + public function testExecuteCallsFunctionWithArguments():void { + $command = new FunctionCommand(); + + $command->execute( + 'Gt\\Cron\\Test\\Helper\\ExampleClass::doSomething("hello", 5)' + ); + + self::assertSame(1, ExampleClass::$calls); + self::assertSame("hello", ExampleClass::$message); + self::assertSame(5, ExampleClass::$counter); + } + + public function testExecuteThrowsForMissingMethod():void { + $command = new FunctionCommand(); + + self::expectException(FunctionExecutionException::class); + $command->execute( + "Gt\\Cron\\Test\\Helper\\ExampleClass::doesNotExist" + ); + } +} diff --git a/test/phpunit/JobTest.php b/test/phpunit/JobTest.php index b259144..0a6695f 100644 --- a/test/phpunit/JobTest.php +++ b/test/phpunit/JobTest.php @@ -5,9 +5,12 @@ use DateTime; use Gt\Cron\CronException; use Gt\Cron\Expression; +use Gt\Cron\FunctionCommand; use Gt\Cron\FunctionExecutionException; use Gt\Cron\Job; +use Gt\Cron\ScriptResult; use Gt\Cron\ScriptOutputMode; +use Gt\Cron\ScriptRunner; use Gt\Cron\ScriptExecutionException; use Gt\Cron\Test\Helper\Override; use PHPUnit\Framework\TestCase; @@ -107,7 +110,9 @@ public function testRunHasRun() { try { $job->run(); } - catch(CronException $exception) {} + catch(CronException $exception) { + self::assertInstanceOf(CronException::class, $exception); + } self::assertTrue($job->hasRun()); } @@ -121,7 +126,9 @@ public function testResetRunFlag() { try { $job->run(); } - catch(CronException $exception) {} + catch(CronException $exception) { + self::assertInstanceOf(CronException::class, $exception); + } $job->resetRunFlag(); self::assertFalse($job->hasRun()); } @@ -163,7 +170,7 @@ public function testRunScriptFail() { ]; Override::setCallback("proc_open", function($command)use(&$procCalls) { $procCalls["proc_open"] []= $command; - return null; + return false; }); Override::load("proc_get_status"); Override::setCallback("proc_close", function()use(&$procCalls) { @@ -176,6 +183,35 @@ public function testRunScriptFail() { self::assertCount(0, $procCalls["proc_close"]); } + public function testRunUsesInjectedDependencies():void { + $functionCommand = $this->createMock(FunctionCommand::class); + $functionCommand->expects(self::once()) + ->method("isCallable") + ->with("example") + ->willReturn(false); + $functionCommand->expects(self::never()) + ->method("execute"); + + $scriptRunner = $this->createMock(ScriptRunner::class); + $scriptRunner->expects(self::once()) + ->method("run") + ->with("example") + ->willReturn(new ScriptResult("out", "err")); + + $job = new Job( + $this->mockExpression(), + "example", + ScriptOutputMode::CAPTURE, + $functionCommand, + $scriptRunner + ); + + $job->run(); + + self::assertSame("out", $job->getStdout()); + self::assertSame("err", $job->getStderr()); + } + public function testRunFunctionNotExists():void { $command = "Gt\\Cron\\Test\\Nothing::thisDoesNotExist"; $job = new Job( diff --git a/test/phpunit/ResolvedScriptCommandTest.php b/test/phpunit/ResolvedScriptCommandTest.php new file mode 100644 index 0000000..07f5160 --- /dev/null +++ b/test/phpunit/ResolvedScriptCommandTest.php @@ -0,0 +1,39 @@ +resolve($command)); + } + + /** @runInSeparateProcess */ + public function testResolveUsesLocalCronScriptAlias():void { + $tempDir = sys_get_temp_dir() . "/cron-test-" . uniqid(); + mkdir($tempDir); + mkdir("$tempDir/cron"); + file_put_contents("$tempDir/cron/example.php", "resolve("example --flag") + ); + } + + /** @runInSeparateProcess */ + public function testResolveIgnoresNestedPaths():void { + $command = "scripts/example.php --flag"; + $resolved = new ResolvedScriptCommand(); + + self::assertSame($command, $resolved->resolve($command)); + } +} diff --git a/test/phpunit/ScriptRunnerTest.php b/test/phpunit/ScriptRunnerTest.php new file mode 100644 index 0000000..bf573f8 --- /dev/null +++ b/test/phpunit/ScriptRunnerTest.php @@ -0,0 +1,77 @@ +run($command); + + self::assertSame("out", $result->stdout); + self::assertSame("err", $result->stderr); + } + + /** @runInSeparateProcess */ + public function testRunUsesInjectedCommandResolver():void { + $resolver = $this->createMock(ResolvedScriptCommand::class); + $resolver->expects(self::once()) + ->method("resolve") + ->with("example") + ->willReturn("resolved-example"); + + $calledCommand = null; + Override::setCallback("proc_open", function($command) use(&$calledCommand) { + $calledCommand = $command; + return "EXAMPLE_PROCESS"; + }); + Override::load("proc_get_status"); + Override::setCallback("proc_close", function() { + }); + + $runner = new ScriptRunner(ScriptOutputMode::DISCARD, $resolver); + $runner->run("example"); + + self::assertSame("resolved-example", $calledCommand); + } + + /** @runInSeparateProcess */ + public function testRunUsesInheritDescriptor():void { + $descriptor = null; + + Override::setCallback("proc_open", function($command, $descriptorArg) use(&$descriptor) { + $descriptor = $descriptorArg; + return "EXAMPLE_PROCESS"; + }); + Override::load("proc_get_status"); + Override::setCallback("proc_close", function() { + }); + + $runner = new ScriptRunner(ScriptOutputMode::INHERIT); + $runner->run("example"); + + self::assertSame(["file", "php://stdout", "w"], $descriptor[1]); + self::assertSame(["file", "php://stderr", "w"], $descriptor[2]); + } + + /** @runInSeparateProcess */ + public function testRunThrowsWhenProcessFails():void { + Override::setCallback("proc_open", function() { + return false; + }); + Override::load("proc_get_status"); + + $runner = new ScriptRunner(); + + self::expectException(ScriptExecutionException::class); + $runner->run("example"); + } +}