Repository: crunzphp/crunz Branch: 3.10 Commit: 98dc47f7997a Files: 174 Total size: 386.9 KB Directory structure: gitextract_lvj5ppvr/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1_Bug_report.md │ │ └── 2_Feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ └── php.yaml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── UPGRADE.md ├── bootstrap.php ├── composer.json ├── config/ │ └── services.php ├── crunz ├── docker/ │ └── php82/ │ ├── Dockerfile │ └── php.ini ├── docker-compose.yml ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml ├── resources/ │ └── config/ │ └── crunz.yml ├── src/ │ ├── Application/ │ │ ├── Cron/ │ │ │ ├── CronExpressionFactoryInterface.php │ │ │ └── CronExpressionInterface.php │ │ ├── Query/ │ │ │ └── TaskInformation/ │ │ │ ├── TaskInformation.php │ │ │ ├── TaskInformationHandler.php │ │ │ └── TaskInformationView.php │ │ └── Service/ │ │ ├── ClosureSerializerInterface.php │ │ ├── ConfigurationInterface.php │ │ └── LoggerFactoryInterface.php │ ├── Application.php │ ├── CacheDirectoryFactory/ │ │ └── CacheDirectoryFactory.php │ ├── Clock/ │ │ ├── Clock.php │ │ └── ClockInterface.php │ ├── Configuration/ │ │ ├── ConfigFileNotExistsException.php │ │ ├── ConfigFileNotReadableException.php │ │ ├── Configuration.php │ │ ├── ConfigurationParser.php │ │ ├── ConfigurationParserInterface.php │ │ ├── Definition.php │ │ └── FileParser.php │ ├── Console/ │ │ └── Command/ │ │ ├── Command.php │ │ ├── ConfigGeneratorCommand.php │ │ ├── ScheduleListCommand.php │ │ ├── ScheduleRunCommand.php │ │ └── TaskGeneratorCommand.php │ ├── EnvFlags/ │ │ └── EnvFlags.php │ ├── Event.php │ ├── EventRunner.php │ ├── Exception/ │ │ ├── CrunzException.php │ │ ├── EmptyTimezoneException.php │ │ ├── MailerException.php │ │ ├── NotImplementedException.php │ │ ├── TaskNotExistException.php │ │ └── WrongTaskNumberException.php │ ├── Filesystem/ │ │ ├── Filesystem.php │ │ └── FilesystemInterface.php │ ├── Finder/ │ │ ├── Finder.php │ │ └── FinderInterface.php │ ├── HttpClient/ │ │ ├── CurlHttpClient.php │ │ ├── FallbackHttpClient.php │ │ ├── HttpClientException.php │ │ ├── HttpClientInterface.php │ │ ├── HttpClientLoggerDecorator.php │ │ └── StreamHttpClient.php │ ├── Infrastructure/ │ │ ├── Dragonmantank/ │ │ │ └── CronExpression/ │ │ │ ├── DragonmantankCronExpression.php │ │ │ └── DragonmantankCronExpressionFactory.php │ │ ├── Laravel/ │ │ │ └── LaravelClosureSerializer.php │ │ └── Psr/ │ │ └── Logger/ │ │ ├── EnabledLoggerDecorator.php │ │ ├── PsrStreamLogger.php │ │ └── PsrStreamLoggerFactory.php │ ├── Invoker.php │ ├── Logger/ │ │ ├── ConsoleLogger.php │ │ ├── ConsoleLoggerInterface.php │ │ ├── Logger.php │ │ └── LoggerFactory.php │ ├── Mailer.php │ ├── Output/ │ │ └── OutputFactory.php │ ├── Path/ │ │ └── Path.php │ ├── Pinger/ │ │ ├── PingableException.php │ │ ├── PingableInterface.php │ │ └── PingableTrait.php │ ├── Process/ │ │ └── Process.php │ ├── Schedule/ │ │ └── ScheduleFactory.php │ ├── Schedule.php │ ├── Stubs/ │ │ └── BasicTask.php │ ├── Task/ │ │ ├── Collection.php │ │ ├── CollectionInterface.php │ │ ├── Loader.php │ │ ├── LoaderInterface.php │ │ ├── TaskException.php │ │ ├── TaskNumber.php │ │ ├── Timezone.php │ │ └── WrongTaskInstanceException.php │ ├── Timezone/ │ │ ├── Provider.php │ │ └── ProviderInterface.php │ └── UserInterface/ │ └── Cli/ │ ├── ClosureRunCommand.php │ └── DebugTaskCommand.php └── tests/ ├── EndToEnd/ │ ├── ClosureRunTest.php │ ├── ConfigProviderTest.php │ ├── ConfigRecognitionTest.php │ ├── DebugTaskTest.php │ ├── LoggerTest.php │ ├── TasksSourceRecognitionTest.php │ ├── VersionTest.php │ └── WrongTaskTest.php ├── Functional/ │ ├── ConfigProviderTest.php │ ├── DifferentBaseCacheDirTest.php │ ├── ScheduleListTest.php │ ├── ScheduleRunTest.php │ └── TaskGeneratorTest.php ├── TestCase/ │ ├── EndToEnd/ │ │ └── Environment/ │ │ ├── Environment.php │ │ └── EnvironmentBuilder.php │ ├── EndToEndTestCase.php │ ├── FakeConfiguration.php │ ├── FakeLoader.php │ ├── FakeTaskCollection.php │ ├── Faker.php │ ├── Logger/ │ │ ├── NullLogger.php │ │ └── SpyPsrLogger.php │ ├── SerializableTaskRunnerStub.php │ ├── TaskRunnerStub.php │ ├── TemporaryFile.php │ ├── TestClock.php │ └── UnitTestCase.php ├── Unit/ │ ├── Application/ │ │ ├── Cron/ │ │ │ └── AbstractCronExpressionTestCase.php │ │ └── Query/ │ │ └── TaskInformation/ │ │ └── TaskInformationHandlerTest.php │ ├── CacheDirectoryFactory/ │ │ └── CacheDirectoryFactoryTest.php │ ├── Configuration/ │ │ ├── ConfigurationParserTest.php │ │ ├── ConfigurationTest.php │ │ └── FileParserTest.php │ ├── Console/ │ │ └── Command/ │ │ ├── ScheduleListCommandTest.php │ │ └── ScheduleRunCommandTest.php │ ├── EnvFlags/ │ │ └── EnvFlagsTest.php │ ├── EventRunnerTest.php │ ├── EventTest.php │ ├── Filesystem/ │ │ └── FilesystemTest.php │ ├── Finder/ │ │ └── FinderTest.php │ ├── HttpClient/ │ │ └── StreamHttpClientTest.php │ ├── Infrastructure/ │ │ ├── Dragonmantank/ │ │ │ └── CronExpression/ │ │ │ └── DragonmantankCronExpressionTestCase.php │ │ └── Psr/ │ │ └── Logger/ │ │ ├── EnabledLoggerDecoratorTest.php │ │ ├── PsrStreamLoggerFactoryTest.php │ │ └── PsrStreamLoggerTest.php │ ├── InvokerTest.php │ ├── Logger/ │ │ ├── ConsoleLoggerTest.php │ │ └── LoggerFactoryTest.php │ ├── MailerTest.php │ ├── Output/ │ │ └── OutputFactoryTest.php │ ├── Path/ │ │ └── PathTest.php │ ├── Pingable.php │ ├── Pinger/ │ │ └── PingableTest.php │ ├── Process/ │ │ └── ProcessTest.php │ ├── Schedule/ │ │ └── ScheduleFactoryTest.php │ ├── ScheduleTest.php │ ├── Service/ │ │ ├── AbstractClosureSerializerTestCase.php │ │ ├── LaravelClosureSerializerTest.php │ │ └── LaravelClosureSerializerTestCase.php │ ├── Task/ │ │ ├── TaskNumberTest.php │ │ └── TimezoneTest.php │ ├── Timezone/ │ │ └── ProviderTest.php │ └── UserInterface/ │ └── Cli/ │ └── ClosureRunCommandTest.php ├── crunz.yml ├── resources/ │ ├── fixtures/ │ │ └── finder/ │ │ ├── direct/ │ │ │ └── directHere.php │ │ └── symlink/ │ │ └── symlinkHere.php │ └── tasks/ │ ├── ClosureTasks.php │ ├── CustomOutputTasks.php │ ├── FailTasks.php │ ├── NoOverlappingClosureTasks.php │ ├── PhpVersionTasks.php │ └── WrongTasks.php └── tasks/ └── TestTasks.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 charset = utf-8 ================================================ FILE: .gitattributes ================================================ * text=auto /tests export-ignore .gitattributes export-ignore .gitignore export-ignore .editorconfig export-ignore README.md export-ignore LICENSE.md export-ignore CHANGELOG.md export-ignore UPGRADE.md export-ignore .travis.yml export-ignore appveyor.yml export-ignore phpunit.xml export-ignore /.github export-ignore .php-cs-fixer.dist.php export-ignore docker-compose.yml export-ignore /docker export-ignore phpstan.neon export-ignore composer-install.php export-ignore bootstrap.php export-ignore /resources/docs export-ignore phpstan-baseline.neon export-ignore ================================================ FILE: .github/FUNDING.yml ================================================ github: PabloKowalczyk ================================================ FILE: .github/ISSUE_TEMPLATE/1_Bug_report.md ================================================ --- name: "\U0001F41B Bug Report" about: Report errors and problems --- **Crunz version**: x.y.z **PHP version**: x.y.z **Operating system type and version**: **Description** **How to reproduce** **Possible Solution** **Additional context** ================================================ FILE: .github/ISSUE_TEMPLATE/2_Feature_request.md ================================================ --- name: "\U0001F680 Feature Request" about: RFC and ideas for new features and improvements --- **Description** **Example** ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ | Q | A | ------------- | --- | Fixed tickets | #... ================================================ FILE: .github/workflows/php.yaml ================================================ name: PHP on: pull_request: branches: - '3.9' - '3.10' push: null permissions: {} concurrency: group: '${{ github.workflow }}-${{ github.ref }}' cancel-in-progress: true jobs: tests: name: '${{ matrix.php }} / Symfony ${{ matrix.symfony_version }} / ${{ matrix.dependencies }} / ${{ matrix.os }}' strategy: matrix: os: - 'ubuntu-22.04' php: - '8.2' - '8.3' - '8.4' - '8.5' dependencies: - 'lowest' - 'highest' symfony_version: - '~6.4.0' - '~7.4.0' - '~8.0.0' include: - os: 'windows-2022' php: '8.2' dependencies: 'highest' symfony_version: '~6.4.0' exclude: - os: 'ubuntu-22.04' php: '8.2' dependencies: 'lowest' symfony_version: '~8.0.0' - os: 'ubuntu-22.04' php: '8.2' dependencies: 'highest' symfony_version: '~8.0.0' - os: 'ubuntu-22.04' php: '8.3' dependencies: 'lowest' symfony_version: '~8.0.0' - os: 'ubuntu-22.04' php: '8.3' dependencies: 'highest' symfony_version: '~8.0.0' runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - id: symfony_packages shell: bash run: | jq --raw-output '"with_string=" + (["--with=" + ((."require" + ."require-dev") | keys[] | select(contains("symfony/"))) + ":${{ matrix.symfony_version }}"] | join(" "))' composer.json \ >>"$GITHUB_OUTPUT" - uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} composer-options: ${{ steps.symfony_packages.outputs.with_string }} - run: composer exec -- phpunit --testsuite EndToEnd - run: composer exec -- phpunit --testsuite Integration - run: composer exec -- phpunit --testsuite Unit static_analysis: name: Static analysis strategy: matrix: include: - php: '8.2' symfony_version: '~6.4.0' dependencies: 'highest' runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - id: symfony_packages shell: bash run: | jq --raw-output '"with_string=" + (["--with=" + ((."require" + ."require-dev") | keys[] | select(contains("symfony/"))) + ":${{ matrix.symfony_version }}"] | join(" "))' composer.json \ >>"$GITHUB_OUTPUT" - uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} composer-options: ${{ steps.symfony_packages.outputs.with_string }} - run: composer normalize --dry-run - uses: actions/cache@v4 with: path: .php-cs-fixer.cache key: php-cs-fixer-cache - uses: actions/cache@v4 with: path: /tmp/phpstan key: phpstan-cache - run: composer run crunz:analyze ================================================ FILE: .gitignore ================================================ /vendor /tasks composer.phar /composer.lock .DS_Store *.log .idea/ var/ .php-cs-fixer.cache /crunz.phar /crunz.yml /.phpunit.result.cache ================================================ FILE: .php-cs-fixer.dist.php ================================================ setParallelConfig(ParallelConfigFactory::detect()) ->setRules([ '@Symfony' => true, 'array_syntax' => ['syntax' => 'short'], 'protected_to_private' => false, 'combine_consecutive_unsets' => true, 'combine_consecutive_issets' => true, 'compact_nullable_type_declaration' => true, 'declare_strict_types' => true, 'dir_constant' => true, 'ereg_to_preg' => true, 'explicit_indirect_variable' => true, 'explicit_string_variable' => true, 'function_to_constant' => true, 'is_null' => true, 'modernize_types_casting' => true, 'linebreak_after_opening_tag' => true, 'list_syntax' => ['syntax' => 'short'], 'mb_str_functions' => true, 'native_function_invocation' => [ 'include' => ['@all'], ], 'no_alias_functions' => true, 'no_homoglyph_names' => true, 'no_php4_constructor' => true, 'no_useless_else' => true, 'no_useless_return' => true, 'ordered_class_elements' => true, 'ordered_imports' => true, 'php_unit_construct' => true, 'php_unit_dedicate_assert' => true, 'php_unit_expectation' => true, 'php_unit_mock' => true, 'php_unit_namespaced' => true, 'php_unit_method_casing' => ['case' => 'snake_case'], 'random_api_migration' => true, 'strict_comparison' => true, 'strict_param' => true, 'ternary_to_null_coalescing' => true, 'void_return' => true, 'concat_space' => [ 'spacing' => 'one', ], 'single_line_throw' => false, 'php_unit_test_case_static_method_calls' => [ 'call_type' => 'self', ], ]) ->setRiskyAllowed(true) ->setFinder( PhpCsFixer\Finder::create() ->in(__DIR__ . '/src') ->in(__DIR__ . '/tests') ->in(__DIR__ . '/config') ->append( [ __FILE__, __DIR__ . '/composer-install.php', ] ) ) ; ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Unreleased ## [v3.7.0] - 2024-08-12 ### Changed - [crunzphp#77] Require at least Symfony 6.4 - [crunzphp#79] Update PHPCSFixer - [crunzphp#78] Allow Symfony v7 ## [v3.6.0] - 2023-11-10 ### Added - [crunzphp#68] Getter for 'from', 'to' event's configuration, thanks to [@lucatacconi] ## [v3.5.1] - 2023-11-03 ### Fixed - [crunzphp#62] Task Life Time functions don't respect the timezone ## [v3.5.0] - 2023-10-15 ### Added - [crunzphp#39] Add PHP v8.2 support - [crunzphp#64] Test Symfony v6.3 - [crunzphp#66] Add PHP v8.3 support ### Removed - [crunzphp#38] Drop PHP v7.4 support - [crunzphp#54] Drop Symfony v6.0 support - [crunzphp#63] Drop Symfony v6.1 support - [crunzphp#65] Drop Symfony v6.2 support ## [v3.4.1] - 2022-11-01 ### Fixed - [crunzphp#44] Do not require BlockingStoreInterface in preventOverlapping() ## [v3.4.0] - 2022-10-03 ### Added - [crunzphp#31] Add format option to `schedule:list` ## [v3.3.0] - 2022-06-19 ### Deprecated - [crunzphp#24] Deprecate non string parameters ## [v3.2.2] - 2022-05-28 ### Fixed - [crunzphp#5] Fix Symfony 6.1 deprecations ## [v3.2.1] - 2022-01-09 ### Fixed - [#401] Fix version number ## [v3.2.0] - 2022-01-09 ### Added - [#398] Add hourlyAt() - [#390] Symfony 6 Support, thanks to [@bashgeek] ### Changed - [#396] Remove `opis/closure` and use `laravel/serializable-closure` instead ### Removed - [#393] Drop Symfony 5.2 support - [#394] Drop Symfony 5.3 support ## [v3.1.0] - 2021-11-24 ### Changed - [#385] Replace `Swiftmailer` with `symfony/mailer` ## [v3.0.1] - 2021-05-25 ### Fixed - [#361] Log to specific event log file, thanks to [@drjayvee] ## [v2.3.1] - 2021-05-25 ### Fixed - [#361] Log to specific event log file, thanks to [@drjayvee] ## [v3.0.0] - 2021-04-25 ### Changed - [#349] Require at least PHP v7.4 - [#356] Require package "dragonmantank/cron-expression" at least "v3.1" ### Removed - [#351] Drop Symfony v3.4 support - [#352] Drop Symfony v5.1 support - [#354] Remove most "Crunz\Event::every*" methods ## [v2.3.0] - 2021-03-14 ### Deprecated - [#344] Deprecate most "Event::every*" methods ### Removed - [#323] Drop Symfony 4.3 support - [#324] Drop Symfony 5.0 support ## [v2.2.4] - 2020-12-18 ### Fixed - [#333] Include symlinks in Finder, thanks to [@iluuu1994] ## [v2.2.3] - 2020-11-29 ### Fixed - [#334] Fix disabling logger not working ## [v2.2.2] - 2020-10-12 ### Fixed - [#326] Fix lock key on closures ## [v2.2.1] - 2020-10-08 ### Fixed - [#321] Add PHP8 support ## [v2.2.0] - 2020-06-18 ### Added - [#287] Add `task:debug` command - [#233] Add option to ignore empty context in monolog, thanks to [@rrushton] - [#298] Add `logger_factory` config option ### Removed - [#292] Drop Symfony 4.2 support ## [v2.1.0] - 2020-02-02 ### Added - [#274] Symfony 5 support ### Changed - [#240] cron-expression package, thanks to [@mareksuscak] - [#280] Hide `closure:run` command ## [v2.0.4] - 2019-12-08 ### Fixed - [#268] Fix Symfony 4.4 deprecations ## [v1.12.4] - 2019-12-08 ### Fixed - [#268] Fix Symfony 4.4 deprecations ## [v2.0.3] - 2019-11-17 ### Fixed - [#261] Release lock on error - [#264] Revert converting closure result to `int` ## [v1.12.3] - 2019-11-17 ### Fixed - [#261] Release lock on error ## [v2.0.2] - 2019-10-06 ### Fixed - [#251] Update PHPUnit to avoid PHP7.4 deprecations ## [v1.12.2] - 2019-10-05 ### Fixed - [#243] Sandbox task loading - [#245] Fix PHP 7.4 compatibility ## [v2.0.1] - 2019-05-10 ### Fixed - [#229] Fix recursive tasks scan ## [v1.12.1] - 2019-05-01 ### Fixed - [#229] Fix recursive tasks scan ## [v2.0.0] - 2019-04-24 ### Changed - [#101] Throw exception on empty timezone - [#204] More than five parts cron expressions will throw exception - [#221] Throw `Crunz\Task\WrongTaskInstanceException` when task is not `Schedule` instance - [#222] Make `\Crunz\Event::setProcess` private - [#225] Bump dependencies ### Removed - [#103] Removed `Crunz\Output\VerbosityAwareOutput` class - [#206] Remove legacy paths recognition - [#224] Remove `mail` transport ## [v1.12.0] - 2019-04-07 ### Added - [#178], [#217] `timezone_log` configuration option to decide whether configured `timezone` should be used for logs, thanks to [@SadeghPM] ### Deprecated - Using `\Crunz\Event::setProcess` is deprecated, this method was intended to be `private`, but for some reason is `public`. In `v2.0` this method will became private and result in exception if you call it. - [#199] Not returning `\Crunz\Schedule` instance from your task is deprecated. In `v2` this will result in exception. ## [v1.11.2] - 2019-03-16 ### Fixed - [#209], [#210] Composer installs crunz executable to vendor/bin instead of symlink ## [v1.11.1] - 2019-01-27 ### Fixed - [#190] Fix Crunz bin path when running closures ## [v1.11.0] - 2019-01-24 ### Fixed - [#181] Fix missing tasks source - [#180] Fix deprecation messages not showing ### Deprecated - Relying on tasks' source/config file recognition related to Crunz bin ## [v1.11.0-rc.1] - 2018-12-22 ### Fixed - [#171] Fix lock storage bug - [#173] Remove Symfony 4.2 deprecations - [#166] Improve task collection debugging ## [v1.11.0-beta.2] - 2018-11-10 ### Fixed - [#162] Fix command error output [closes [#161]] ## [v1.11.0-beta.1] - 2018-10-23 ### Added - [#153] Add support for `symfony/lock`, Thanks to [@digilist] ### Fixed - [#146] Make paths relative to current working directory - "cwd". - [#158] Accept only string task number. ## [v1.10.1] - 2018-09-22 ### Fixed - [#139] Do not require `cURL` extension ## [v1.10.0] - 2018-09-22 ### Fixed - [#137] Treat whole output of failed command as "error output". ### Removed - [#136] Remove guzzle ## [v1.9.0] - 2018-08-18 ### Changed - [#132] Improved container caching in shared servers ### Fixed - [#131] Crunz can be used with `dragonmantank/cron-expression` package ### Deprecated - Passing more than five parts (e.g `* * * * * *`) to `Crunz\Event::cron()` ## [v1.8.0] - 2018-08-15 ### Added - [#120] Added `--force` option to `schedule:run` command - [#129] Add `--task` option for `schedule:run` command ### Fixed - [#123] Spellfix: `comand` -> `command`, Thanks to [@FallDi] ## [v1.7.3] - 2018-06-15 - [#118] Undefined index: year in `vendor/lavary/crunz/src/Event.php` on line 370, Thanks to [@mindcreations] ## [v1.7.2] - 2018-06-13 ### Fixed - [#116] Do not replace Symfony's polyfills. ## [v1.7.1] - 2018-06-01 ### Fixed - [#110] Fixed config file path guessing. ## [v1.7.0] - 2018-05-27 ### Added - [#94] Added timezone option ### Deprecated - `timezone` option in config file is now required, lack of it will result in Exception in version `2.0` ### Removed - [#104] Remove splitCamel helper. ## [v1.6.1] - 2018-05-13 ### Fixed - [#90] Send output by email only if it is not empty. ## [v1.6.0] - 2018-04-22 ### Added - [#69] Option for allowing line breaks in logs, Thanks to [@TomasDuda] - [#79] Introduce DI container ### Fixed - [#43] Typos stopping email transport of 'mail', Thanks to [@m-hume] - [#46] sendOutputTo and appendOutputTo fix, Thanks to [@m-hume] - [#80] Fixed prevent overlapping on windows - [#81] Fix Event::in on windows - [#84] Make comparing date segments strict. - [#86] Fix closure running on windows - [#85] Fix changing user - [#87] Remove error handler ## [v1.5.1] - 2018-04-12 ### Added - [#76] Introduce editorconfig - [#75] Added changelog file. ### Fixed - [#77] Fix high cpu usage [#401]: https://github.com/lavary/crunz/pull/401 [#398]: https://github.com/lavary/crunz/pull/398 [#396]: https://github.com/lavary/crunz/pull/396 [#394]: https://github.com/lavary/crunz/pull/394 [#393]: https://github.com/lavary/crunz/pull/393 [#390]: https://github.com/lavary/crunz/pull/390 [#385]: https://github.com/lavary/crunz/pull/385 [#361]: https://github.com/lavary/crunz/pull/361 [#356]: https://github.com/lavary/crunz/pull/356 [#354]: https://github.com/lavary/crunz/pull/354 [#352]: https://github.com/lavary/crunz/pull/352 [#351]: https://github.com/lavary/crunz/pull/351 [#349]: https://github.com/lavary/crunz/pull/349 [#344]: https://github.com/lavary/crunz/pull/344 [#334]: https://github.com/lavary/crunz/pull/334 [#333]: https://github.com/lavary/crunz/pull/333 [#326]: https://github.com/lavary/crunz/pull/326 [#324]: https://github.com/lavary/crunz/pull/324 [#323]: https://github.com/lavary/crunz/pull/323 [#321]: https://github.com/lavary/crunz/pull/321 [#298]: https://github.com/lavary/crunz/pull/298 [#292]: https://github.com/lavary/crunz/pull/292 [#287]: https://github.com/lavary/crunz/pull/287 [#280]: https://github.com/lavary/crunz/pull/280 [#274]: https://github.com/lavary/crunz/pull/274 [#268]: https://github.com/lavary/crunz/pull/268 [#264]: https://github.com/lavary/crunz/pull/264 [#261]: https://github.com/lavary/crunz/pull/261 [#251]: https://github.com/lavary/crunz/pull/251 [#245]: https://github.com/lavary/crunz/pull/245 [#243]: https://github.com/lavary/crunz/pull/243 [#240]: https://github.com/lavary/crunz/pull/240 [#233]: https://github.com/lavary/crunz/pull/233 [#229]: https://github.com/lavary/crunz/pull/229 [#225]: https://github.com/lavary/crunz/pull/225 [#224]: https://github.com/lavary/crunz/pull/224 [#222]: https://github.com/lavary/crunz/pull/222 [#221]: https://github.com/lavary/crunz/pull/221 [#217]: https://github.com/lavary/crunz/pull/217 [#210]: https://github.com/lavary/crunz/pull/210 [#209]: https://github.com/lavary/crunz/pull/209 [#206]: https://github.com/lavary/crunz/pull/206 [#204]: https://github.com/lavary/crunz/pull/204 [#199]: https://github.com/lavary/crunz/pull/199 [#190]: https://github.com/lavary/crunz/pull/190 [#181]: https://github.com/lavary/crunz/pull/181 [#180]: https://github.com/lavary/crunz/pull/180 [#178]: https://github.com/lavary/crunz/pull/178 [#173]: https://github.com/lavary/crunz/pull/173 [#171]: https://github.com/lavary/crunz/pull/171 [#166]: https://github.com/lavary/crunz/pull/166 [#164]: https://github.com/lavary/crunz/pull/164 [#163]: https://github.com/lavary/crunz/pull/163 [#162]: https://github.com/lavary/crunz/pull/162 [#161]: https://github.com/lavary/crunz/pull/161 [#159]: https://github.com/lavary/crunz/pull/159 [#158]: https://github.com/lavary/crunz/pull/158 [#157]: https://github.com/lavary/crunz/pull/157 [#155]: https://github.com/lavary/crunz/pull/155 [#154]: https://github.com/lavary/crunz/pull/154 [#153]: https://github.com/lavary/crunz/pull/153 [#151]: https://github.com/lavary/crunz/pull/151 [#150]: https://github.com/lavary/crunz/pull/150 [#149]: https://github.com/lavary/crunz/pull/149 [#148]: https://github.com/lavary/crunz/pull/148 [#147]: https://github.com/lavary/crunz/pull/147 [#146]: https://github.com/lavary/crunz/pull/146 [#142]: https://github.com/lavary/crunz/pull/142 [#141]: https://github.com/lavary/crunz/pull/141 [#140]: https://github.com/lavary/crunz/pull/140 [#139]: https://github.com/lavary/crunz/pull/139 [#138]: https://github.com/lavary/crunz/pull/138 [#137]: https://github.com/lavary/crunz/pull/137 [#136]: https://github.com/lavary/crunz/pull/136 [#133]: https://github.com/lavary/crunz/pull/133 [#132]: https://github.com/lavary/crunz/pull/132 [#131]: https://github.com/lavary/crunz/pull/131 [#130]: https://github.com/lavary/crunz/pull/130 [#129]: https://github.com/lavary/crunz/pull/129 [#123]: https://github.com/lavary/crunz/pull/123 [#120]: https://github.com/lavary/crunz/pull/120 [#119]: https://github.com/lavary/crunz/pull/119 [#118]: https://github.com/lavary/crunz/pull/118 [#117]: https://github.com/lavary/crunz/pull/117 [#116]: https://github.com/lavary/crunz/pull/116 [#113]: https://github.com/lavary/crunz/pull/113 [#112]: https://github.com/lavary/crunz/pull/112 [#111]: https://github.com/lavary/crunz/pull/111 [#110]: https://github.com/lavary/crunz/pull/110 [#109]: https://github.com/lavary/crunz/pull/109 [#107]: https://github.com/lavary/crunz/pull/107 [#105]: https://github.com/lavary/crunz/pull/105 [#104]: https://github.com/lavary/crunz/pull/104 [#103]: https://github.com/lavary/crunz/pull/103 [#102]: https://github.com/lavary/crunz/pull/102 [#101]: https://github.com/lavary/crunz/pull/101 [#100]: https://github.com/lavary/crunz/pull/100 [#98]: https://github.com/lavary/crunz/pull/98 [#97]: https://github.com/lavary/crunz/pull/97 [#96]: https://github.com/lavary/crunz/pull/96 [#95]: https://github.com/lavary/crunz/pull/95 [#94]: https://github.com/lavary/crunz/pull/94 [#92]: https://github.com/lavary/crunz/pull/92 [#90]: https://github.com/lavary/crunz/pull/90 [#89]: https://github.com/lavary/crunz/pull/89 [#88]: https://github.com/lavary/crunz/pull/88 [#87]: https://github.com/lavary/crunz/pull/87 [#86]: https://github.com/lavary/crunz/pull/86 [#85]: https://github.com/lavary/crunz/pull/85 [#84]: https://github.com/lavary/crunz/pull/84 [#82]: https://github.com/lavary/crunz/pull/82 [#81]: https://github.com/lavary/crunz/pull/81 [#80]: https://github.com/lavary/crunz/pull/80 [#79]: https://github.com/lavary/crunz/pull/79 [#77]: https://github.com/lavary/crunz/pull/77 [#76]: https://github.com/lavary/crunz/pull/76 [#75]: https://github.com/lavary/crunz/pull/75 [#74]: https://github.com/lavary/crunz/pull/74 [#73]: https://github.com/lavary/crunz/pull/73 [#72]: https://github.com/lavary/crunz/pull/72 [#69]: https://github.com/lavary/crunz/pull/69 [#50]: https://github.com/lavary/crunz/pull/50 [#46]: https://github.com/lavary/crunz/pull/46 [#43]: https://github.com/lavary/crunz/pull/43 [#36]: https://github.com/lavary/crunz/pull/36 [#25]: https://github.com/lavary/crunz/pull/25 [#24]: https://github.com/lavary/crunz/pull/24 [#23]: https://github.com/lavary/crunz/pull/23 [#17]: https://github.com/lavary/crunz/pull/17 [#16]: https://github.com/lavary/crunz/pull/16 [crunzphp#5]: https://github.com/crunzphp/crunz/pull/5 [crunzphp#24]: https://github.com/crunzphp/crunz/pull/24 [crunzphp#31]: https://github.com/crunzphp/crunz/pull/31 [crunzphp#38]: https://github.com/crunzphp/crunz/pull/38 [crunzphp#39]: https://github.com/crunzphp/crunz/pull/39 [crunzphp#44]: https://github.com/crunzphp/crunz/pull/44 [crunzphp#54]: https://github.com/crunzphp/crunz/pull/54 [crunzphp#62]: https://github.com/crunzphp/crunz/pull/62 [crunzphp#63]: https://github.com/crunzphp/crunz/pull/63 [crunzphp#64]: https://github.com/crunzphp/crunz/pull/64 [crunzphp#65]: https://github.com/crunzphp/crunz/pull/65 [crunzphp#66]: https://github.com/crunzphp/crunz/pull/66 [crunzphp#68]: https://github.com/crunzphp/crunz/pull/68 [crunzphp#77]: https://github.com/crunzphp/crunz/pull/77 [crunzphp#78]: https://github.com/crunzphp/crunz/pull/78 [crunzphp#79]: https://github.com/crunzphp/crunz/pull/79 [v1.5.1]: https://github.com/crunzphp/crunz/compare/v1.5.0...v1.5.1 [v1.6.0]: https://github.com/crunzphp/crunz/compare/v1.5.1...v1.6.0 [v1.6.1]: https://github.com/crunzphp/crunz/compare/v1.6.0...v1.6.1 [v1.7.0]: https://github.com/crunzphp/crunz/compare/v1.6.1...v1.7.0 [v1.7.1]: https://github.com/crunzphp/crunz/compare/v1.7.0...v1.7.1 [v1.7.2]: https://github.com/crunzphp/crunz/compare/v1.7.1...v1.7.2 [v1.7.3]: https://github.com/crunzphp/crunz/compare/v1.7.2...v1.7.3 [v1.8.0]: https://github.com/crunzphp/crunz/compare/v1.7.3...v1.8.0 [v1.9.0]: https://github.com/crunzphp/crunz/compare/v1.8.0...v1.9.0 [v1.10.0]: https://github.com/crunzphp/crunz/compare/v1.9.0...v1.10.0 [v1.10.1]: https://github.com/crunzphp/crunz/compare/v1.10.0...v1.10.1 [v1.11.0-beta.1]: https://github.com/crunzphp/crunz/compare/v1.10.1...v1.11.0-beta.1 [v1.11.0-beta.2]: https://github.com/crunzphp/crunz/compare/v1.11.0-beta.1...v1.11.0-beta.2 [v1.11.0-rc.1]: https://github.com/crunzphp/crunz/compare/v1.11.0-beta.2...v1.11.0-rc.1 [v1.11.0]: https://github.com/crunzphp/crunz/compare/v1.11.0-rc.1...v1.11.0 [v1.11.1]: https://github.com/crunzphp/crunz/compare/v1.11.0...v1.11.1 [v1.11.2]: https://github.com/crunzphp/crunz/compare/v1.11.1...v1.11.2 [v1.12.0]: https://github.com/crunzphp/crunz/compare/v1.11.2...v1.12.0 [v1.12.1]: https://github.com/crunzphp/crunz/compare/v1.12.0...v1.12.1 [v1.12.2]: https://github.com/crunzphp/crunz/compare/v1.12.1...v1.12.2 [v1.12.3]: https://github.com/crunzphp/crunz/compare/v1.12.2...v1.12.3 [v1.12.4]: https://github.com/crunzphp/crunz/compare/v1.12.3...v1.12.4 [v2.0.0]: https://github.com/crunzphp/crunz/compare/v1.12.0...v2.0.0 [v2.0.1]: https://github.com/crunzphp/crunz/compare/v2.0.0...v2.0.1 [v2.0.2]: https://github.com/crunzphp/crunz/compare/v2.0.1...v2.0.2 [v2.0.3]: https://github.com/crunzphp/crunz/compare/v2.0.2...v2.0.3 [v2.0.4]: https://github.com/crunzphp/crunz/compare/v2.0.3...v2.0.4 [v2.1.0]: https://github.com/crunzphp/crunz/compare/v2.0.4...v2.1.0 [v2.2.0]: https://github.com/crunzphp/crunz/compare/v2.1.0...v2.2.0 [v2.2.1]: https://github.com/crunzphp/crunz/compare/v2.2.0...v2.2.1 [v2.2.2]: https://github.com/crunzphp/crunz/compare/v2.2.1...v2.2.2 [v2.2.3]: https://github.com/crunzphp/crunz/compare/v2.2.2...v2.2.3 [v2.2.4]: https://github.com/crunzphp/crunz/compare/v2.2.3...v2.2.4 [v2.3.0]: https://github.com/crunzphp/crunz/compare/v2.2.4...v2.3.0 [v2.3.1]: https://github.com/crunzphp/crunz/compare/v2.3.0...v2.3.1 [v3.0.0]: https://github.com/crunzphp/crunz/compare/v2.3.1...v3.0.0 [v3.0.1]: https://github.com/crunzphp/crunz/compare/v3.0.0...v3.0.1 [v3.1.0]: https://github.com/crunzphp/crunz/compare/v3.0.1...v3.1.0 [v3.2.0]: https://github.com/crunzphp/crunz/compare/v3.1.0...v3.2.0 [v3.2.1]: https://github.com/crunzphp/crunz/compare/v3.2.0...v3.2.1 [v3.2.2]: https://github.com/crunzphp/crunz/compare/v3.2.1...v3.2.2 [v3.3.0]: https://github.com/crunzphp/crunz/compare/v3.2.2...v3.3.0 [v3.4.0]: https://github.com/crunzphp/crunz/compare/v3.3.0...v3.4.0 [v3.4.1]: https://github.com/crunzphp/crunz/compare/v3.4.0...v3.4.1 [v3.5.0]: https://github.com/crunzphp/crunz/compare/v3.4.1...v3.5.0 [v3.5.1]: https://github.com/crunzphp/crunz/compare/v3.5.0...v3.5.1 [v3.6.0]: https://github.com/crunzphp/crunz/compare/v3.5.1...v3.6.0 [v3.7.0]: https://github.com/crunzphp/crunz/compare/v3.6.0...v3.7.0 [@andrewmy]: https://github.com/andrewmy [@arthurbarros]: https://github.com/arthurbarros [@bashgeek]: https://github.com/bashgeek [@codermarcel]: https://github.com/codermarcel [@digilist]: https://github.com/digilist [@drjayvee]: https://github.com/drjayvee [@erfan723]: https://github.com/erfan723 [@FallDi]: https://github.com/FallDi [@iluuu1994]: https://github.com/iluuu1994 [@jhoughtelin]: https://github.com/jhoughtelin [@lucatacconi]: https://github.com/lucatacconi [@m-hume]: https://github.com/m-hume [@mareksuscak]: https://github.com/mareksuscak [@mindcreations]: https://github.com/mindcreations [@PhilETaylor]: https://github.com/PhilETaylor [@radarhere]: https://github.com/radarhere [@rrushton]: https://github.com/rrushton [@SadeghPM]: https://github.com/SadeghPM [@timurbakarov]: https://github.com/timurbakarov [@TomasDuda]: https://github.com/TomasDuda [@vinkla]: https://github.com/vinkla ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Moe Reza Lavarian Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ sh-php: docker compose exec --user=www-data php82 sh ================================================ FILE: README.md ================================================ # Crunz needs your funding 💲 ## Support further Crunz development by [GitHub](https://github.com/sponsors/PabloKowalczyk). Check [more info](https://github.com/crunzphp/crunz/issues/111). # Crunz Install a cron job once and for all, manage the rest from the code. Crunz is a framework-agnostic package to schedule periodic tasks (cron jobs) in PHP using a fluent API. Crunz is capable of executing any kind of executable command as well as PHP closures. [![Version](http://img.shields.io/packagist/v/crunzphp/crunz.svg?style=flat-square)](https://packagist.org/packages/crunzphp/crunz) [![Packagist](https://img.shields.io/packagist/dt/crunzphp/crunz.svg?style=flat-square)](https://packagist.org/packages/crunzphp/crunz/stats) [![Packagist](https://img.shields.io/packagist/dm/crunzphp/crunz.svg?style=flat-square)](https://packagist.org/packages/crunzphp/crunz/stats) ## Roadmap | Version | Release date | Active support until | Bug support until | Status | |---------|--------------|----------------------|-------------------|----------------| | v1.x | April 2016 | April 2019 | April 2020 | End of life | | v2.x | April 2019 | April 2021 | April 2022 | End of life | | v3.x | April 2021 | TBD | TBD | Active support | | v4.x | TBD | TBD | TBD | Development | ## Installation To install it: ```bash composer require crunzphp/crunz ``` If the installation is successful, a command-line utility named **crunz** is symlinked to the `vendor/bin` directory of your project. ## How It Works? The idea is very simple: instead of installing cron jobs in a crontab file, we define them in one or several PHP files, by using the Crunz interface. Here's a basic example: ```php run('cp project project-bk'); $task->daily(); return $schedule; ``` To run the tasks, you only need to install an ordinary cron job (a crontab entry) which runs **every minute**, and delegates the responsibility to Crunz' event runner: ```bash * * * * * cd /project && vendor/bin/crunz schedule:run ``` The command `schedule:run` is responsible for collecting all the PHP task files and run the tasks which are due. ## Task Files Task files resemble crontab files. Just like crontab files they can contain one or more tasks. Normally we create our task files in the `tasks/` directory within the project's root directory. > By default, Crunz assumes all the task files reside in the `tasks/` directory within the project's root directory. There are two ways to specify the source directory: 1) Configuration file 2) As a parameter to the event runner command. We can explicitly set the source path by passing it to the event runner as a parameter: ```bash * * * * * cd /project && vendor/bin/crunz schedule:run /path/to/tasks/directory ``` ### Creating a Simple Task In the terminal, change the directory to your project's root directory and run the following commands: ```bash mkdir tasks && cd tasks nano GeneralTasks.php ``` Then, add a task as below: ```php run('cp project project-bk'); $task ->daily() ->description('Create a backup of the project directory.'); // ... // IMPORTANT: You must return the schedule object return $schedule; ``` There are some conventions for creating a task file, which you need to follow. First of all, the filename should end with `Tasks.php` unless we change this via the configuration settings. In addition to that, we **must** return the instance of `Schedule` class at the end of each file, otherwise, all the tasks inside the file will be skipped by the event runner. Since Crunz scans the tasks directory recursively, we can either put all the tasks in one file or across different files (or directories) based on their usage. This behavior helps us have a well organized tasks directory. ## The Command We can run **any** command or script by using `run()`. This method accepts two arguments: **the command to be executed**, and **the command options** (as an associative array) if there's any. ### Normal Command or Script ```php run(PHP_BINARY . ' backup.php', ['--destination' => 'path/to/destination']); $task ->everyMinute() ->description('Copying the project directory'); return $schedule; ``` In the above example, `--destination` is an option supported by `backup.php` script. ### Closures We can also write to a closure instead of a command: ```php run(function() use ($x) { // Do some cool stuff in here }); $task ->everyMinute() ->description('Copying the project directory'); return $schedule; ``` ## Frequency of Execution There are a variety of ways to specify **when** and **how often** a task should run. We can combine these methods together to get our desired frequencies. ### Units of Time There are a group of methods which specify a unit of time (bigger than minute) as frequency. They usually end with `ly` suffix, as in `hourly()`, `daily()`, `weekly`, `monthly()`, `quarterly()`, and `yearly` . All the events scheduled with this set of methods happen at the **beginning** of that time unit. For example `weekly()` will run the event on Sundays, and `monthly()` will run on the first day of each month. The task below will run **daily at midnight** (start of the daily time period). ```php run(PHP_BINARY . ' backup.php'); $task->daily(); // ... ``` Here's another one, which runs on the **first day of each month**. ```php run(PHP_BINARY . ' email.php'); $task->monthly(); // ... ``` ### Running Events at Certain Times To schedule a one-off tasks, you may use `on()` method like this: ```php run(PHP_BINARY . ' email.php'); $task->on('13:30 2016-03-01'); // ... ``` The above task will run on the first of march 2016 at 01:30 pm. > `On()` accepts any date format parsed by PHP's [strtotime](http://php.net/manual/en/function.strtotime.php) function. To specify the time of a task we use `at()` method: ```php run(PHP_BINARY . ' email.php'); $task ->daily() ->at('13:30'); // ... ``` If we only pass a time to the `on()` method, it will have the same effect as using `at()` ```php run(PHP_BINARY . ' email.php'); $task ->daily() ->on('13:30'); // is the sames as $task = $schedule->run(PHP_BINARY . ' email.php'); $task ->daily() ->at('13:30'); // ... ``` We can combine the "Unit of Time" methods eg. daily(), monthly() with the at() or on() constraint in a single statement if we wish. The following task will be run every hour at the 15th minute ```php run(PHP_BINARY . ' feedmecookie.php'); $task ->hourlyAt('15'); // ... ``` >hourlyOn('15') could have been used instead of hourlyAt('15') with the same result The following task will be run Monday at 13:30 ```php run(PHP_BINARY . ' startofwork.php'); $task ->weeklyOn(1,'13:30'); // ... ``` >Sunday is considered day 0 of the week. If we wished for the task to run on Tuesday (day 2 of the week) at 09:00 we would have used: ```php run(PHP_BINARY . ' startofwork.php'); $task ->weeklyOn(2,'09:00'); // ... ``` ## Task Life Time In a crontab entry, we can not easily specify a task's lifetime (the period of time when the task is active). However, it's been made easy in Crunz: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->from('12:30 2016-03-04') ->to('04:55 2016-03-10'); // ``` Or alternatively we can use the `between()` method to accomplish the same result: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->between('12:30 2016-03-04', '04:55 2016-03-10'); // ``` If we don't specify the date portion, the task will be active **every** day but only within the specified duration: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->between('12:30', '04:55'); // ``` The above task runs **every five minutes** between **12:30 pm** and **4:55 pm** every day. An example of restricting a task from running only during a certain range of minutes each hour can be achieved as follows: ```php run(PHP_BINARY . ' email.php'); $task ->hourly() ->between($startminute, $endminute); // ``` The above task runs **every hour** between **minutes 5 to 15** ### Weekdays Crunz also provides a set of methods which specify a certain day in the week. * `mondays()` * `tuesdays()` * `wednesdays()` * `thursdays()` * `fridays()` * `saturdays()` * `sundays()` * `weekdays()` These methods have been designed to be used as a constraint and should not be used alone. The reason is that weekday methods just modify the `Day of Week` field of a cron job expression. Consider the following example: ```php run(PHP_BINARY . ' startofwork.php'); $task->mondays(); ``` At first glance, the task seems to run **every Monday**, but since it only modifies the "day of week" field of the cron job expression, the task runs **every minute on Mondays**. This is the correct way of using weekday methods: ```php run(PHP_BINARY . ' startofwork.php'); $task ->mondays() ->at('13:30'); // ... ``` >(An easier to read alternative with a similar result ->weeklyOn(0,'13:30') to that shown in a previously example above) ### The Classic Way We can also do the scheduling the old way, just like we do in a crontab file: ```php run(PHP_BINARY . ' email.php'); $task->cron('30 12 * 5-6,9 Mon,Fri'); ``` ### Setting Individual Fields Crunz's methods are not limited to the ready-made methods explained. We can also set individual fields to compose custom frequencies similar to how a classic crontab would composed them. These methods either accept an array of values, or list arguments separated by commas: ```php run(PHP_BINARY . ' email.php'); $task ->minute(['1-30', 45, 55]) ->hour('1-5', 7, 8) ->dayOfMonth(12, 15) ->month(1); ``` Or: ```php run(PHP_BINARY . ' email.php'); $task ->minute('30') ->hour('13') ->month([1,2]) ->dayofWeek('Mon', 'Fri', 'Sat'); // ... ``` Based on our use cases, we can choose and combine the proper set of methods, which are easier to use. ## Running Conditions Another thing that we cannot do very easily in a traditional crontab file is to make conditions for running the tasks. This has been made easy by `when()` and `skip()` methods. Consider the following code: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->between('12:30 2016-03-04', '04:55 2016-03-10') ->when(function() { if ((bool) (time() % 2)) { return true; } return false; }); ``` Method `when()` accepts a callback, which must return `TRUE` for the task to run. This is really useful when we need to check our resources before performing a resource-hungry task. We can also skip a task under certain conditions, by using `skip()` method. If the passed callback returns `TRUE`, the task will be skipped. ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->between('12:30 2016-03-04', '04:55 2016-03-10') ->skip(function() { if ((bool) (time() % 2)) { return true; } return false; }); // ``` We can use these methods **several** times for a single task. They are evaluated sequentially. ## Changing Directories You can use the `in()` method to change directory before running a command: ```php run('./deploy.sh'); $task ->in('/home') ->weekly() ->sundays() ->at('12:30') ->appendOutputTo('/var/log/backup.log'); // ... return $schedule; ``` ## Parallelism and the Locking Mechanism Crunz runs the scheduled events in parallel (in separate processes), so all the events which have the same frequency of execution, will run at the same time asynchronously. To achieve this, Crunz utilizes the [symfony/Process](http://symfony.com/doc/current/components/process.html) library for running the tasks in sub-processes. If the execution of a task lasts until the next interval or even beyond that, we say that the same instances of a task are overlapping. In some cases, this is a not a problem. But there are times, when these tasks are modifying database data or files, which might cause unexpected behaviors, or even data loss. To prevent critical tasks from overlapping each other, Crunz provides a locking mechanism through `preventOverlapping()` method, which, ensures no task runs if the previous instance is already running. ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->preventOverlapping(); // ``` By default, crunz uses file based locking (if no parameters are passed to `preventOverlapping`). For alternative lock mechanisms, crunz uses the [symfony/lock](https://symfony.com/doc/current/components/lock.html) component that provides lock mechanisms with various stores. To use this component, you can pass a store to the `preventOverlapping()` method. In the following example, the file based `FlockStore` is used to provide an alternative lock file path. ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->preventOverlapping($store); ``` ## Keeping the Output Cron jobs usually have outputs, which is normally emailed to the owner of the crontab file, or the user(s) set by the `MAILTO` environment variable inside the crontab file. We can also redirect the standard output to a physical file using `>` or `>>` operators: ```bash * * * * * /command/to/run >> /var/log/crons/cron.log ``` This kind of output logging has been automated in Crunz. To automatically send each event's output to a log file, we can set `log_output` and `output_log_file` options in the configuration file accordingly: ```yaml # Configuration settings ## ... log_output: true output_log_file: /var/log/crunz.log ## ... ``` This will send the events' output (if executed successfully) to `/var/log/crunz.log` file. However, we need to make sure we are permitted to write to the respective file. If we need to log the outputs on an event-basis, we can use `appendOutputTo()` or `sendOutputTo()` methods like this: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->appendOutputTo('/var/log/crunz/emails.log'); // ``` Method `appendOutputTo()` **appends** the output to the specified file. To override the log file with new data after each run, we use `saveOutputTo()` method. It is also possible to send the errors as emails to a group of recipients by setting `email_output` and `mailer` settings in the configuration file. ## Error Handling Crunz makes error handling easy by logging and also allowing you add a set of callbacks in case of an error. ## Error Callbacks You can set as many callbacks as needed to run in case of an error: ```php run('command/to/run'); $task->everyFiveMinutes(); $schedule ->onError(function() { // Send mail }) ->onError(function() { // Do something else }); return $schedule; ``` If there's an error the two defined callbacks will be executed. ## Error Logging To log the possible errors during each run, we can set `log_error` and `error_log_file` settings in the configuration file as below: ```yaml # Configuration settings # ... log_errors: true errors_log_file: /var/log/error.log # ... ``` As a result, if the execution of an event is unsuccessful for some reasons, the error message is appended to the specified error log file. Each entry provides useful information including the time it happened, the event description, the executed command which caused the error, and the error message itself. It is also possible to send the errors as emails to a group of recipients by setting `email_error` and `mailer` settings in the configuration file. ## Custom logger To use your own logger create class implementing `\Crunz\Application\Service\LoggerFactoryInterface`, for example: ```php run(PHP_BINARY . ' email.php'); $task ->everyFiveMinutes() ->before(function() { // Do something before the task runs }) ->before(function() { // Do something else }) ->after(function() { // After the task is run }); $schedule ->before(function () { // Do something before all events }) ->after(function () { // Do something after all events are finished }) ->before(function () { // Do something before all events }); ``` > We might need to use these methods as many times we need by chaining them. Post-execution callbacks are only called if the execution of the event has been successful. ## Other Useful Commands We've already used a few of `crunz` commands like `schedule:run` and `publish:config`. To see all the valid options and arguments of `crunz`, we can run the following command: ```bash vendor/bin/crunz --help ``` ### Listing Tasks One of these commands is `crunz schedule:list`, which lists the defined tasks (in collected `*.Tasks.php` files) in a tabular format. ```text vendor/bin/crunz schedule:list +---+---------------+-------------+--------------------+ | # | Task | Expression | Command to Run | +---+---------------+-------------+--------------------+ | 1 | Sample Task | * * * * 1 * | command/to/execute | +---+---------------+-------------+--------------------+ ``` By default, list is in text format, but format can be changed by `--format` option. List in `json` format, command: ```bash vendor/bin/crunz schedule:list --format json ``` will output: ```json [ { "number": 1, "task": "Sample Task", "expression": "* * * * 1", "command": "command/to/execute" } ] ``` ### Force run While in development it may be useful to force run all tasks regardless of their actual run time, which can be achieved by adding `--force` to `schedule:run`: ```bash vendor/bin/crunz schedule:run --force ``` To force run a single task, use the schedule:list command above to determine the Task number and run as follows: ```bash vendor/bin/crunz schedule:run --task 1 --force ``` ### Generating Tasks There is also a useful command named `make:task`, which generates a task file skeleton with all the defaults, so we won't have to write them from scratch. We can modify the output file later based on our requirements. For example, to create a task, which runs `/var/www/script.php` every hour on Mondays, we run the following command: ```text vendor/bin/crunz make:task exampleOne --run scripts.php --in /var/www --frequency everyHour --constraint mondays Where do you want to save the file? (Press enter for the current directory) ``` When we run this command, Crunz will ask about the location we want to save the file. By default, it is our source tasks directory. As a result, the event is defined in a file named `exampleOneTasks.php` within the specified tasks directory. To see if the event has been created successfully, we list the events: ```text crunz schedule:list +---+------------------+-------------+----------------+ | # | Task | Expression | Command to Run | +---+------------------+-------------+----------------+ | 1 | Task description | 0 * * * 1 * | scripts.php | +---+------------------+-------------+----------------+ ``` To see all the options of `make:task` command with all the defaults, we run this: ```bash vendor/bin/crunz make:task --help ``` ### Debugging tasks To show basic information about task run: ```bash vendor/bin/crunz task:debug 1 ``` Above command should output something like this: ```text +----------------------+-----------------------------------+ | Debug information for task '1' | +----------------------+-----------------------------------+ | Command to run | php -v | | Description | Inner task | | Prevent overlapping | No | +----------------------+-----------------------------------+ | Cron expression | * * * * * | | Comparisons timezone | Europe/Warsaw (from config) | +----------------------+-----------------------------------+ | Example run dates | | #1 | 2020-03-08 09:27:00 Europe/Warsaw | | #2 | 2020-03-08 09:28:00 Europe/Warsaw | | #3 | 2020-03-08 09:29:00 Europe/Warsaw | | #4 | 2020-03-08 09:30:00 Europe/Warsaw | | #5 | 2020-03-08 09:31:00 Europe/Warsaw | +----------------------+-----------------------------------+ ``` ## Configuration There are a few configuration options provided by Crunz in YAML format. To modify the configuration settings, it is highly recommended to have your own copy of the configuration file, instead of modifying the original one. To create a copy of the configuration file, first we need to publish the configuration file: ```bash /project/vendor/bin/crunz publish:config The configuration file was generated successfully ``` As a result, a copy of the configuration file will be created within our project's root directory. The configuration file looks like this: ```yaml # Crunz Configuration Settings # This option defines where the task files and # directories reside. # The path is relative to the project's root directory, # where the Crunz is installed (Trailing slashes will be ignored). source: tasks # The suffix is meant to target the task files inside the ":source" directory. # Please note if you change this value, you need # to make sure all the existing tasks files are renamed accordingly. suffix: Tasks.php # Timezone is used to calculate task run time # This option is very important and not setting it is deprecated # and will result in exception in 2.0 version. timezone: ~ # This option define which timezone should be used for log files # If false, system default timezone will be used # If true, the timezone in config file that is used to calculate task run time will be used timezone_log: false # By default the errors are not logged by Crunz # You may set the value to true for logging the errors log_errors: false # This is the absolute path to the errors' log file # You need to make sure you have the required permission to write to this file though. errors_log_file: # By default the output is not logged as they are redirected to the # null output. # Set this to true if you want to keep the outputs log_output: false # This is the absolute path to the global output log file # The events which have dedicated log files (defined with them), won't be # logged to this file though. output_log_file: # By default line breaks in logs aren't allowed. # Set the value to true to allow them. log_allow_line_breaks: false # By default empty context arrays are shown in the log. # Set the value to true to remove them. log_ignore_empty_context: false # This option determines whether the output should be emailed or not. email_output: false # This option determines whether the error messages should be emailed or not. email_errors: false # Global Swift Mailer settings # mailer: # Possible values: smtp, mail, and sendmail transport: smtp recipients: sender_name: sender_email: # SMTP settings # smtp: host: port: username: password: encryption: ``` As you can see there are a few options like `source` which is used to specify the source tasks directory. The other options are used for error/output logging/emailing purposes. Each time we run Crunz commands, it will look into the project's root directory to see if there's any user-modified configuration file. If the configuration file doesn't exists, it will use the one shipped with the package. ## Setting the base cache directory You can change the base cache directory of crunz by setting the `CRUNZ_BASE_CACHE_DIR` environment variable. The default base cache directory is `\sys_get_temp_dir()`. The subdirectory `.crunz` is always added to the base cache directory. ## Development ENV flags The following environment flags should be used only while in development. Typical end-users do not need to, and should not, change them. ### `CRUNZ_CONTAINER_DEBUG` Flag used to enable/disable container debug mode, useful only for development. Enabled by default in `docker-compose`. ### `CRUNZ_DEPRECATION_HANDLER` Flag used to enable/disable Crunz deprecation handler, useful only for integration tests. Disabled by default for tests. ## Sponsors [![Blakfire.io logo](resources/docs/blackfire-logo.png)](https://www.blackfire.io/?utm_source=crunz&utm_medium=readme&utm_campaign=free-open-source) ## Support You can support further Crunz development by [GitHub](https://github.com/sponsors/PabloKowalczyk). ## Contributing ### Which branch should I choose? Bug fixes and readme changes should target `3.9`, new features should target `3.10`. ## If You Need Help Please submit all issues and questions using GitHub issues and I will try to help you. ## Credits * [PabloKowalczyk](https://github.com/PabloKowalczyk) * [Reza Lavarian](https://github.com/lavary) * [All Contributors](https://github.com/crunzphp/crunz/graphs/contributors) ## License Crunz is free software distributed under the terms of the MIT license. ================================================ FILE: UPGRADE.md ================================================ # Upgrading from v3.2 to v3.3 ## Pass only string parameters to `\Crunz\Schedule::run` Convert this: ```php $schedule = new Schedule(); $schedule->run('php', ['-v' => true, 2]); ``` into this: ```php $schedule = new Schedule(); $schedule->run('php', ['-v' => '1', '2']); ``` # Upgrading from v1.12 to v2.0 ## Stop using `mail` transport for mailer As of `v6.0` SwiftMailer dropped support for `mail` transport, so `Crunz` `v2.0` won't support it either, please use `smtp` or `sendmail` transport. # Upgrading from v1.11 to v1.12 ## Always return `\Crunz\Schedule` from task files Example of wrong task file: ```php run('php -v') ->description('PHP version') ->everyMinute(); // Crunz\Schedule instance returned return $scheduler; ``` ## Stop using `\Crunz\Event::setProcess` If you, for some reason, use above method you should stop it. This method was intended to be `private` and will be in `v2.0`, which will lead to exception if you call it. Example of wrong usage ```php run('php -v'); $task // setProcess is deprecated ->setProcess($process) ->description('PHP version') ->everyMinute() ; return $scheduler; ``` # Upgrading from v1.10 to v1.11 ## Run `Crunz` in directory with your `crunz.yml` Searching for Crunz's config is now related to `cwd`, not to `vendor/bin/crunz`. For example, if your `crunz.yml` is in `/var/www/project/crunz.yml`, then run Crunz with `cd` first: ```bash cd /var/www/project && vendor/bin/crunz schedule:list ``` Cron job also should be changed: ```bash * * * * * cd /var/www/project && vendor/bin/crunz schedule:run ``` # Upgrading from v1.9 to v1.10 ### Do not pass more than five parts to `Crunz\Event::cron()` Example correct call: ```yaml $event = new Crunz\Event; $event->cron('0 * * * *'); ``` # Upgrading from v1.7 to v1.8 ### Add `timezone` to your `crunz.yml` Example config file: ```yaml source: tasks suffix: Tasks.php timezone: Europe/Warsaw ``` ================================================ FILE: bootstrap.php ================================================ disableDeprecationHandler(); // Make sure current working directory is "tests" $filesystem = new \Crunz\Filesystem\Filesystem(); if (\str_contains($filesystem->getCwd(), 'tests')) { return; } if (!\chdir('tests')) { throw new RuntimeException("Unable to change current directory to 'tests'."); } ================================================ FILE: composer.json ================================================ { "name": "crunzphp/crunz", "description": "Schedule your tasks right from the code.", "license": "MIT", "type": "library", "keywords": [ "scheduler", "cron jobs", "cron", "Task Scheduler", "PHP Task Scheduler", "Job Scheduler", "Job Manager", "Event Runner" ], "authors": [ { "name": "Reza M. Lavaryan", "email": "mrl.8081@gmail.com" }, { "name": "PabloKowalczyk", "homepage": "https://github.com/PabloKowalczyk", "role": "Developer" } ], "homepage": "https://github.com/crunzphp/crunz", "support": { "issues": "https://github.com/crunzphp/crunz/issues" }, "funding": [ { "type": "github", "url": "https://github.com/sponsors/PabloKowalczyk" } ], "require": { "php": ">=8.2", "composer-runtime-api": "^2.0", "dragonmantank/cron-expression": "^3.4.0", "laravel/serializable-closure": "^2.0", "psr/log": "^2.0 || ^3.0", "symfony/config": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/console": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/dependency-injection": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/filesystem": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/lock": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/mailer": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/process": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/string": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/yaml": "^6.4.25 || ^7.4.0 || ^8.0.0" }, "require-dev": { "ext-json": "*", "ext-mbstring": "*", "ergebnis/composer-normalize": "2.28.3", "friendsofphp/php-cs-fixer": "3.90.0", "phpstan/phpstan": "2.0.2", "phpstan/phpstan-phpunit": "2.0.1", "phpstan/phpstan-strict-rules": "2.0.0", "phpunit/phpunit": "10.5.63", "symfony/error-handler": "^6.4.25 || ^7.4.0 || ^8.0.0", "symfony/phpunit-bridge": "^6.4.25 || ^7.4.0 || ^8.0.0" }, "conflict": { "laravel/serializable-closure": ">=2.0.9,<2.0.11" }, "minimum-stability": "beta", "prefer-stable": true, "autoload": { "psr-4": { "Crunz\\": "src/" } }, "autoload-dev": { "psr-4": { "Crunz\\Tests\\": "tests/" } }, "bin": [ "crunz" ], "config": { "allow-plugins": { "ergebnis/composer-normalize": true }, "sort-packages": true }, "scripts": { "crunz:analyze": [ "@php vendor/bin/php-cs-fixer fix --diff --dry-run -v", "@phpstan:check" ], "crunz:cs-fix": "@php vendor/bin/php-cs-fixer fix --diff -v --ansi", "phpstan:check": "@php vendor/bin/phpstan analyse -c phpstan.neon src tests crunz config bootstrap.php" } } ================================================ FILE: config/services.php ================================================ Provider::class, Filesystem::class, ScheduleFactory::class, StreamHttpClient::class, CurlHttpClient::class, FilesystemInterface::class => CrunzFilesystem::class, FinderInterface::class => Finder::class, LoaderInterface::class => Loader::class, CronExpressionFactoryInterface::class => DragonmantankCronExpressionFactory::class, ClosureSerializerInterface::class => LaravelClosureSerializer::class, ClockInterface::class => Clock::class, ]; /* @var ContainerBuilder $container */ $container ->register(ScheduleRunCommand::class, ScheduleRunCommand::class) ->setPublic(true) ->setArguments( [ new Reference(CollectionInterface::class), new Reference(ConfigurationInterface::class), new Reference(EventRunner::class), new Reference(Timezone::class), new Reference(ScheduleFactory::class), new Reference(LoaderInterface::class), ] ) ; $container ->register(ClosureRunCommand::class, ClosureRunCommand::class) ->setArguments( [ new Reference(ClosureSerializerInterface::class), ] ) ->setPublic(true) ; $container ->register(ConfigGeneratorCommand::class, ConfigGeneratorCommand::class) ->setPublic(true) ->setArguments( [ new Reference(ProviderInterface::class), new Reference(Filesystem::class), new Reference(FilesystemInterface::class), ] ) ; $container ->register(ScheduleListCommand::class, ScheduleListCommand::class) ->setPublic(true) ->setArguments( [ new Reference(ConfigurationInterface::class), new Reference(CollectionInterface::class), new Reference(LoaderInterface::class), ] ) ; $container ->register(TaskGeneratorCommand::class, TaskGeneratorCommand::class) ->setPublic(true) ->setArguments( [ new Reference(ConfigurationInterface::class), new Reference(FilesystemInterface::class), ] ) ; $container ->register(DebugTaskCommand::class, DebugTaskCommand::class) ->setPublic(true) ->setArguments( [ new Reference(TaskInformationHandler::class), ] ) ; $container ->register(CollectionInterface::class, Collection::class) ->setPublic(false) ->setArguments( [ new Reference(ConfigurationInterface::class), new Reference(FinderInterface::class), new Reference(ConsoleLoggerInterface::class), ] ) ; $container ->register(FileParser::class, FileParser::class) ->setPublic(false) ->setArguments( [ new Reference(Yaml::class), ] ) ; $container ->register(ConfigurationInterface::class, Configuration::class) ->setPublic(false) ->setArguments( [ new Reference(ConfigurationParserInterface::class), new Reference(FilesystemInterface::class), ] ) ; $container ->register(Mailer::class, Mailer::class) ->setPublic(false) ->setArguments( [ new Reference(ConfigurationInterface::class), ] ) ; $container ->register(LoggerFactory::class, LoggerFactory::class) ->setPublic(false) ->setArguments( [ new Reference(ConfigurationInterface::class), new Reference(Timezone::class), new Reference(ConsoleLoggerInterface::class), new Reference(ClockInterface::class), ] ) ; $container ->register(EventRunner::class, EventRunner::class) ->setPublic(false) ->setArguments( [ new Reference(Invoker::class), new Reference(ConfigurationInterface::class), new Reference(Mailer::class), new Reference(LoggerFactory::class), new Reference(HttpClientInterface::class), new Reference(ConsoleLoggerInterface::class), ] ) ; $container ->register(Timezone::class, Timezone::class) ->setPublic(false) ->setArguments( [ new Reference(ConfigurationInterface::class), new Reference(ConsoleLoggerInterface::class), ] ) ; $container ->register(OutputInterface::class, ConsoleOutput::class) ->setPublic(true) ->setFactory([new Reference(OutputFactory::class), 'createOutput']) ; $container ->register(OutputFactory::class, OutputFactory::class) ->setPublic(false) ->setArguments( [ new Reference(InputInterface::class), ] ) ; $container ->register(InputInterface::class, ArgvInput::class) ->setPublic(true) ; $container ->register(SymfonyStyle::class, SymfonyStyle::class) ->setPublic(true) ->setArguments( [ new Reference(InputInterface::class), new Reference(OutputInterface::class), ] ) ; $container ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) ->setPublic(false) ->setArguments( [ new Reference(SymfonyStyle::class), ] ) ; $container ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) ->setPublic(false) ->setArguments( [ new Reference(SymfonyStyle::class), ] ) ; $container ->register(FallbackHttpClient::class, FallbackHttpClient::class) ->setPublic(false) ->setArguments( [ new Reference(StreamHttpClient::class), new Reference(CurlHttpClient::class), new Reference(ConsoleLoggerInterface::class), ] ) ; $container ->register(HttpClientInterface::class, HttpClientLoggerDecorator::class) ->setPublic(false) ->setArguments( [ new Reference(FallbackHttpClient::class), new Reference(ConsoleLoggerInterface::class), ] ) ; $container ->register(ConfigurationParserInterface::class, ConfigurationParser::class) ->setPublic(false) ->setArguments( [ new Reference(Definition::class), new Reference(Processor::class), new Reference(FileParser::class), new Reference(ConsoleLoggerInterface::class), new Reference(FilesystemInterface::class), ] ) ; $container ->register(TaskInformationHandler::class, TaskInformationHandler::class) ->setPublic(false) ->setArguments( [ new Reference(Timezone::class), new Reference(ConfigurationInterface::class), new Reference(CollectionInterface::class), new Reference(LoaderInterface::class), new Reference(ScheduleFactory::class), new Reference(CronExpressionFactoryInterface::class), ] ) ; foreach ($simpleServices as $id => $simpleService) { if (!\is_string($id)) { $id = $simpleService; } $container ->register($id, $simpleService) ->setPublic(false) ; } ================================================ FILE: crunz ================================================ #!/usr/bin/env php | For the full copyright and license information, please view the LICENSE | file that was distributed with this source code. | */ use Composer\InstalledVersions; use Crunz\Application; if (!\defined('CRUNZ_BIN')) { \define('CRUNZ_BIN', __FILE__); } $generatePath = static fn(string ...$parts): string => \implode(DIRECTORY_SEPARATOR, $parts); $autoloadPaths = [ // Dependency $generatePath( \dirname(__DIR__, 2), 'autoload.php' ), // Vendor/Bin $generatePath( \dirname(__DIR__), 'autoload.php' ), // Local dev $generatePath( __DIR__, 'vendor', 'autoload.php' ), ]; $loadAutoloader = static function () use($autoloadPaths): void { foreach ($autoloadPaths as $autoloadPath) { if (\file_exists($autoloadPath) === true) { require_once $autoloadPath; return; } } throw new RuntimeException( \sprintf( 'Unable to find "vendor/autoload.php" in "%s" paths.', \implode('", "', $autoloadPaths) ) ); }; $loadAutoloader(); $application = new Application( 'Crunz Command Line Interface', InstalledVersions::getPrettyVersion('crunzphp/crunz') ?? '1.0.x-dev', ); $application->run(); ================================================ FILE: docker/php82/Dockerfile ================================================ FROM php:8.2.30-cli-alpine RUN apk add --no-cache \ shadow \ su-exec && \ usermod --non-unique --uid 1000 www-data && \ apk del \ shadow && \ docker-php-ext-install -j$(nproc) \ opcache \ sysvsem RUN mkdir -p \ /var/log/php \ /var/www/.composer \ && touch /var/log/php/error.log \ && chown www-data:www-data \ /var/log/php/error.log \ /var/www/.composer COPY --from=composer/composer:2.9.3-bin /composer /usr/bin/composer ENV COMPOSER_HOME /var/www/.composer ================================================ FILE: docker/php82/php.ini ================================================ realpath_cache_size = 8192k realpath_cache_ttl = 6000 expose_php = On error_log = /var/log/php/error.log error_reporting = E_ALL display_errors = On display_startup_errors = On log_errors = On report_memleaks = On memory_limit = 80M date.timezone = "UTC" zend.assertions = 1 opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=80 opcache.interned_strings_buffer=5 opcache.max_accelerated_files=3000 opcache.validate_timestamps=1 opcache.revalidate_freq=0 opcache.save_comments=1 opcache.fast_shutdown=1 opcache.huge_code_pages=1 ================================================ FILE: docker-compose.yml ================================================ services: php82: build: context: ./docker/php82 working_dir: /var/www/html environment: CRUNZ_CONTAINER_DEBUG: 1 command: > sh -c " chown -R www-data:www-data /var/www/.composer && \ echo 'Logs from /var/log/php/error.log:' && \ touch /var/log/php/error.log && \ tail -f /var/log/php/error.log " volumes: - .:/var/www/html - ./docker/php82/php.ini:/usr/local/etc/php/php.ini:ro stop_grace_period: 1s ================================================ FILE: phpstan-baseline.neon ================================================ parameters: ignoreErrors: - message: "#^Parameter \\#5 \\$timeZone of class Crunz\\\\Application\\\\Query\\\\TaskInformation\\\\TaskInformationView constructor expects DateTimeZone\\|null, mixed given\\.$#" count: 1 path: src/Application/Query/TaskInformation/TaskInformationHandler.php - message: "#^Variable property access on \\$this\\(Crunz\\\\Application\\\\Query\\\\TaskInformation\\\\TaskInformationHandler\\)\\.$#" count: 1 path: src/Application/Query/TaskInformation/TaskInformationHandler.php - message: "#^Parameter \\#1 \\$parts of static method Crunz\\\\Path\\\\Path\\:\\:create\\(\\) expects array\\, array\\ given\\.$#" count: 1 path: src/Configuration/Configuration.php - message: "#^Method Crunz\\\\Configuration\\\\ConfigurationParser\\:\\:parseConfig\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: src/Configuration/ConfigurationParser.php - message: "#^Variable \\$cwd on left side of \\?\\? always exists and is not nullable\\.$#" count: 1 path: src/Configuration/ConfigurationParser.php - message: "#^Method Crunz\\\\Configuration\\\\ConfigurationParserInterface\\:\\:parseConfig\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: src/Configuration/ConfigurationParserInterface.php - message: "#^Method Crunz\\\\Configuration\\\\FileParser\\:\\:parse\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: src/Configuration/FileParser.php - message: "#^Method Crunz\\\\Configuration\\\\FileParser\\:\\:parse\\(\\) should return array\\ but returns array\\\\.$#" count: 1 path: src/Configuration/FileParser.php - message: "#^Property Crunz\\\\Console\\\\Command\\\\Command\\:\\:\\$arguments type has no value type specified in iterable type array\\.$#" count: 1 path: src/Console/Command/Command.php - message: "#^Property Crunz\\\\Console\\\\Command\\\\Command\\:\\:\\$options type has no value type specified in iterable type array\\.$#" count: 1 path: src/Console/Command/Command.php - message: "#^Method Crunz\\\\Console\\\\Command\\\\ConfigGeneratorCommand\\:\\:askForTimezone\\(\\) should return string but returns mixed\\.$#" count: 1 path: src/Console/Command/ConfigGeneratorCommand.php - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 path: src/Console/Command/ConfigGeneratorCommand.php - message: "#^Only booleans are allowed in a negated boolean, int\\<0, max\\> given\\.$#" count: 1 path: src/Console/Command/ScheduleListCommand.php - message: "#^Only booleans are allowed in a negated boolean, int\\<0, max\\> given\\.$#" count: 2 path: src/Console/Command/ScheduleRunCommand.php - message: "#^Binary operation \"\\.\" between array\\|string\\|null and mixed results in an error\\.$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Call to an undefined method Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperInterface\\:\\:ask\\(\\)\\.$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Method Crunz\\\\Console\\\\Command\\\\TaskGeneratorCommand\\:\\:type\\(\\) should return string but returns array\\|bool\\|float\\|int\\|string\\|null\\.$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Only booleans are allowed in an if condition, string given\\.$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Parameter \\#1 \\$string of function rtrim expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 2 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 3 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Return type \\(int\\|null\\) of method Crunz\\\\Console\\\\Command\\\\TaskGeneratorCommand\\:\\:execute\\(\\) should be covariant with return type \\(int\\) of method Symfony\\\\Component\\\\Console\\\\Command\\\\Command\\:\\:execute\\(\\)$#" count: 1 path: src/Console/Command/TaskGeneratorCommand.php - message: "#^Call to function is_string\\(\\) with string will always evaluate to true\\.$#" count: 1 path: src/Event.php - message: "#^Cannot cast mixed to string\\.$#" count: 1 path: src/Event.php - message: "#^Instanceof between Closure and Closure will always evaluate to true\\.$#" count: 1 path: src/Event.php - message: "#^Instanceof between DateTimeZone and DateTimeZone will always evaluate to true\\.$#" count: 1 path: src/Event.php - message: "#^Only booleans are allowed in a negated boolean, mixed given\\.$#" count: 1 path: src/Event.php - message: "#^Only booleans are allowed in an if condition, DateTimeZone\\|string given\\.$#" count: 1 path: src/Event.php - message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" count: 2 path: src/Event.php - message: "#^Only booleans are allowed in an if condition, string given\\.$#" count: 3 path: src/Event.php - message: "#^Property Crunz\\\\Event\\:\\:\\$lock \\(Symfony\\\\Component\\\\Lock\\\\Lock\\) does not accept Symfony\\\\Component\\\\Lock\\\\SharedLockInterface\\.$#" count: 1 path: src/Event.php - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 path: src/Event.php - message: "#^Strict comparison using \\=\\=\\= between false and array\\ will always evaluate to false\\.$#" count: 1 path: src/Event.php - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 path: src/EventRunner.php - message: "#^Only booleans are allowed in &&, mixed given on the left side\\.$#" count: 2 path: src/EventRunner.php - message: "#^Only booleans are allowed in a negated boolean, int\\<0, max\\> given\\.$#" count: 1 path: src/EventRunner.php - message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" count: 2 path: src/EventRunner.php - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 path: src/EventRunner.php - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(mixed\\)\\: mixed\\)\\|null, Closure\\(array\\)\\: SplFileInfo given\\.$#" count: 1 path: src/Finder/Finder.php - message: "#^Method Crunz\\\\Infrastructure\\\\Laravel\\\\LaravelClosureSerializer\\:\\:extractWrapper\\(\\) should return Laravel\\\\SerializableClosure\\\\SerializableClosure but returns mixed\\.$#" count: 1 path: src/Infrastructure/Laravel/LaravelClosureSerializer.php - message: "#^Method Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\EnabledLoggerDecorator\\:\\:log\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/EnabledLoggerDecorator.php - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLogger.php - message: "#^Method Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger\\:\\:log\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLogger.php - message: "#^Parameter \\#1 \\$message of method Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger\\:\\:replaceNewlines\\(\\) expects string, string\\|Stringable given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLogger.php - message: "#^Parameter \\#1 \\$string of function mb_strtoupper expects string, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLogger.php - message: "#^Parameter \\#3 \\$outputStreamPath of class Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger constructor expects string\\|null, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php - message: "#^Parameter \\#4 \\$errorStreamPath of class Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger constructor expects string\\|null, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php - message: "#^Parameter \\#5 \\$ignoreEmptyContext of class Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger constructor expects bool, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php - message: "#^Parameter \\#6 \\$timezoneLog of class Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger constructor expects bool, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php - message: "#^Parameter \\#7 \\$allowLineBreaks of class Crunz\\\\Infrastructure\\\\Psr\\\\Logger\\\\PsrStreamLogger constructor expects bool, mixed given\\.$#" count: 1 path: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php - message: "#^Method Crunz\\\\Logger\\\\LoggerFactory\\:\\:createLoggerFactory\\(\\) should return Crunz\\\\Application\\\\Service\\\\LoggerFactoryInterface but returns object\\.$#" count: 1 path: src/Logger/LoggerFactory.php - message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" count: 1 path: src/Logger/LoggerFactory.php - message: "#^Parameter \\#1 \\$class of function class_exists expects string, mixed given\\.$#" count: 1 path: src/Logger/LoggerFactory.php - message: "#^Part \\$loggerFactoryClass \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 2 path: src/Logger/LoggerFactory.php - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" count: 1 path: src/Mailer.php - message: "#^Only booleans are allowed in an if condition, Symfony\\\\Component\\\\Mailer\\\\Mailer\\|null given\\.$#" count: 1 path: src/Mailer.php - message: "#^Parameter \\#1 \\$address of class Symfony\\\\Component\\\\Mime\\\\Address constructor expects string, mixed given\\.$#" count: 1 path: src/Mailer.php - message: "#^Parameter \\#1 \\.\\.\\.\\$addresses of method Symfony\\\\Component\\\\Mime\\\\Email\\:\\:addTo\\(\\) expects string\\|Symfony\\\\Component\\\\Mime\\\\Address, mixed given\\.$#" count: 1 path: src/Mailer.php - message: "#^Parameter \\#2 \\$name of class Symfony\\\\Component\\\\Mime\\\\Address constructor expects string, mixed given\\.$#" count: 1 path: src/Mailer.php - message: "#^Part \\$host \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: src/Mailer.php - message: "#^Part \\$password \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: src/Mailer.php - message: "#^Part \\$port \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: src/Mailer.php - message: "#^Part \\$user \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: src/Mailer.php - message: "#^Only booleans are allowed in \\|\\|, mixed given on the right side\\.$#" count: 1 path: src/Output/OutputFactory.php - message: "#^Call to function is_string\\(\\) with string will always evaluate to true\\.$#" count: 1 path: src/Schedule.php - message: "#^Only booleans are allowed in &&, int\\<0, max\\> given on the right side\\.$#" count: 1 path: src/Schedule.php - message: "#^Parameter \\#2 \\$suffix of method Crunz\\\\Finder\\\\FinderInterface\\:\\:find\\(\\) expects string, mixed given\\.$#" count: 1 path: src/Task/Collection.php - message: "#^Part \\$suffix \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: src/Task/Collection.php - message: "#^Call to function is_string\\(\\) with string will always evaluate to true\\.$#" count: 1 path: src/Task/TaskNumber.php - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 path: src/Task/Timezone.php - message: "#^Parameter \\#1 \\$timezone of class DateTimeZone constructor expects string, mixed given\\.$#" count: 1 path: src/Task/Timezone.php - message: "#^Part \\$newTimezone \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 2 path: src/Task/Timezone.php - message: "#^Return type \\(int\\|null\\) of method Crunz\\\\UserInterface\\\\Cli\\\\ClosureRunCommand\\:\\:execute\\(\\) should be covariant with return type \\(int\\) of method Symfony\\\\Component\\\\Console\\\\Command\\\\Command\\:\\:execute\\(\\)$#" count: 1 path: src/UserInterface/Cli/ClosureRunCommand.php - message: "#^Method Crunz\\\\Tests\\\\EndToEnd\\\\WrongTaskTest\\:\\:scheduleInstanceProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/EndToEnd/WrongTaskTest.php - message: "#^Call to an undefined method Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperInterface\\:\\:setInputStream\\(\\)\\.$#" count: 1 path: tests/Functional/TaskGeneratorTest.php - message: "#^Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Console\\\\Tester\\\\CommandTester and 'setInputs' will always evaluate to true\\.$#" count: 1 path: tests/Functional/TaskGeneratorTest.php - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 2 path: tests/TestCase/EndToEnd/Environment/Environment.php - message: "#^Casting to string something that's already string\\.$#" count: 1 path: tests/TestCase/EndToEndTestCase.php - message: "#^Cannot cast mixed to string\\.$#" count: 1 path: tests/TestCase/FakeConfiguration.php - message: "#^Method Crunz\\\\Tests\\\\TestCase\\\\FakeConfiguration\\:\\:__construct\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" count: 1 path: tests/TestCase/FakeConfiguration.php - message: "#^Property Crunz\\\\Tests\\\\TestCase\\\\FakeConfiguration\\:\\:\\$config \\(array\\\\) does not accept array\\\\.$#" count: 1 path: tests/TestCase/FakeConfiguration.php - message: "#^Property Crunz\\\\Tests\\\\TestCase\\\\FakeConfiguration\\:\\:\\$config type has no value type specified in iterable type array\\.$#" count: 1 path: tests/TestCase/FakeConfiguration.php - message: "#^Parameter \\#1 \\$timezone of class DateTimeZone constructor expects string, mixed given\\.$#" count: 1 path: tests/TestCase/Faker.php - message: "#^Method Crunz\\\\Tests\\\\TestCase\\\\Logger\\\\SpyPsrLogger\\:\\:getLogs\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/TestCase/Logger/SpyPsrLogger.php - message: "#^Method Crunz\\\\Tests\\\\TestCase\\\\Logger\\\\SpyPsrLogger\\:\\:log\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 path: tests/TestCase/Logger/SpyPsrLogger.php - message: "#^Property Crunz\\\\Tests\\\\TestCase\\\\Logger\\\\SpyPsrLogger\\:\\:\\$logs type has no value type specified in iterable type array\\.$#" count: 1 path: tests/TestCase/Logger/SpyPsrLogger.php - message: "#^Offset 'uri' on array\\{timed_out\\: bool, blocked\\: bool, eof\\: bool, unread_bytes\\: int, stream_type\\: string, wrapper_type\\: string, wrapper_data\\: mixed, mode\\: string, \\.\\.\\.\\} on left side of \\?\\? always exists and is not nullable\\.$#" count: 1 path: tests/TestCase/TemporaryFile.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Application\\\\Cron\\\\AbstractCronExpressionTest\\:\\:multipleRunDatesProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Application/Cron/AbstractCronExpressionTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Application\\\\Query\\\\TaskInformation\\\\TaskInformationHandlerTest\\:\\:taskInformationProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Application/Query/TaskInformation/TaskInformationHandlerTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Configuration\\\\ConfigurationTest\\:\\:createConfiguration\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Configuration/ConfigurationTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\EnvFlags\\\\EnvFlagsTest\\:\\:containerDebugProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/EnvFlags/EnvFlagsTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\EnvFlags\\\\EnvFlagsTest\\:\\:statusProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/EnvFlags/EnvFlagsTest.php - message: "#^Call to method expects\\(\\) on an unknown class Symfony\\\\Component\\\\Lock\\\\StoreInterface\\.$#" count: 1 path: tests/Unit/EventRunnerTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\EventTest\\:\\:deprecatedEveryProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/EventTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\EventTest\\:\\:everyMethodProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/EventTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\EventTest\\:\\:hourlyAtInvalidProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/EventTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Filesystem\\\\FilesystemTest\\:\\:fileExistsProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Filesystem/FilesystemTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Finder\\\\FinderTest\\:\\:tasksProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Finder/FinderTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Infrastructure\\\\Psr\\\\Logger\\\\EnabledLoggerDecoratorTest\\:\\:disabledChannelProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Infrastructure/Psr/Logger/EnabledLoggerDecoratorTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Infrastructure\\\\Psr\\\\Logger\\\\EnabledLoggerDecoratorTest\\:\\:enabledChannelProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Infrastructure/Psr/Logger/EnabledLoggerDecoratorTest.php - message: "#^Parameter \\#1 \\$config of class Crunz\\\\Tests\\\\TestCase\\\\FakeConfiguration constructor expects array\\, array\\ given\\.$#" count: 1 path: tests/Unit/Logger/LoggerFactoryTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Output\\\\OutputFactoryTest\\:\\:inputProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Output/OutputFactoryTest.php - message: "#^Call to function is_string\\(\\) with string will always evaluate to true\\.$#" count: 1 path: tests/Unit/Pingable.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Pinger\\\\PingableTest\\:\\:nonStringProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Pinger/PingableTest.php - message: "#^Parameter \\#1 \\$url of method Crunz\\\\Tests\\\\Unit\\\\Pingable\\:\\:pingBefore\\(\\) expects string, mixed given\\.$#" count: 1 path: tests/Unit/Pinger/PingableTest.php - message: "#^Parameter \\#1 \\$url of method Crunz\\\\Tests\\\\Unit\\\\Pingable\\:\\:thenPing\\(\\) expects string, mixed given\\.$#" count: 1 path: tests/Unit/Pinger/PingableTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Task\\\\TaskNumberTest\\:\\:nonNumericProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Task/TaskNumberTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Task\\\\TaskNumberTest\\:\\:nonStringValueProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Task/TaskNumberTest.php - message: "#^Method Crunz\\\\Tests\\\\Unit\\\\Task\\\\TaskNumberTest\\:\\:numericValueProvider\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: tests/Unit/Task/TaskNumberTest.php - message: "#^Parameter \\#1 \\$value of static method Crunz\\\\Task\\\\TaskNumber\\:\\:fromString\\(\\) expects string, mixed given\\.$#" count: 1 path: tests/Unit/Task/TaskNumberTest.php ================================================ FILE: phpstan.neon ================================================ parameters: level: 9 reportUnmatchedIgnoredErrors: false inferPrivatePropertyTypeFromConstructor: true ignoreErrors: - message: '#Variable \$container might not be defined#' path: config/services.php - message: '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\)#' path: src/Configuration/Definition.php - message: '#Variable \$configFile might not be defined#' path: src/Configuration/ConfigurationParser.php - message: '#Call to an undefined method Crunz\\Event::DummyFrequency\(\)#' path: src/Stubs/BasicTask.php - message: '#Parameter \#1 \$command of static method Symfony\\Component\\Process\\Process::fromShellCommandline\(\) expects string#' path: src/Process/Process.php - message: '#Result of#' path: src/Event.php - message: '#Parameter \#1 \$store of class#' path: src/Event.php - message: '#CrunzContainer#' path: src/Application.php - message: '#Parameter \#2 \$currentTime#' path: src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpression.php includes: - phpstan-baseline.neon - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/rules.neon - vendor/phpstan/phpstan-strict-rules/rules.neon ================================================ FILE: phpunit.xml ================================================ src tests/EndToEnd tests/Functional tests/Unit ================================================ FILE: resources/config/crunz.yml ================================================ # Crunz Configuration Settings # This option defines where the task files and # directories reside. # The path is relative to this config file. # Trailing slashes will be ignored. source: tasks # The suffix is meant to target the task files inside the ":source" directory. # Please note if you change this value, you need # to make sure all the existing tasks files are renamed accordingly. suffix: Tasks.php # Timezone is used to calculate task run time # This option is very important and not setting it is deprecated # and will result in exception in 2.0 version. timezone: ~ # This option define which timezone should be used for log files # If false, system default timezone will be used # If true, the timezone in config file that is used to calculate task run time will be used timezone_log: false # By default the errors are not logged by Crunz # You may set the value to true for logging the errors log_errors: false # This is the absolute path to the errors' log file # You need to make sure you have the required permission to write to this file though. errors_log_file: ~ # By default the output is not logged as they are redirected to the # null output. # Set this to true if you want to keep the outputs log_output: false # This is the absolute path to the global output log file # The events which have dedicated log files (defined with them), won't be # logged to this file though. output_log_file: ~ # By default line breaks in logs aren't allowed. # Set the value to true to allow them. log_allow_line_breaks: false # By default empty context arrays are shown in the log. # Set the value to true to remove them. log_ignore_empty_context: false # This option determines whether the output should be emailed or not. email_output: false # This option determines whether the error messages should be emailed or not. email_errors: false # Global Swift Mailer settings mailer: # Possible values: smtp, mail, and sendmail transport: smtp recipients: sender_name: sender_email: # SMTP settings smtp: host: ~ port: ~ username: ~ password: ~ encryption: ~ ================================================ FILE: src/Application/Cron/CronExpressionFactoryInterface.php ================================================ taskNumber; } } ================================================ FILE: src/Application/Query/TaskInformation/TaskInformationHandler.php ================================================ configuration ->getSourcePath() ; /** @var \SplFileInfo[] $files */ $files = $this->taskCollection ->all($source) ; // List of schedules $schedules = $this->taskLoader ->load(...\array_values($files)) ; $timezoneForComparisons = $this->timezone ->timezoneForComparisons() ; $event = $this->scheduleFactory ->singleTask($taskInformation->taskNumber(), ...$schedules) ; $cronExpression = $this->cronExpressionFactory ->createFromString($event->getExpression()) ; $nextRunTimezone = $timezoneForComparisons; $eventProperties = $this->getEventProperties($event, ['timezone', 'preventOverlapping']); $eventTimezone = $eventProperties['timezone']; if (\is_string($eventTimezone)) { $eventTimezone = new \DateTimeZone($eventTimezone); $nextRunTimezone = $eventTimezone; } $nextRuns = $cronExpression->multipleRunDates( 5, new \DateTimeImmutable(), $nextRunTimezone ); return new TaskInformationView( $event->getCommand(), $event->description ?? '', $event->getExpression(), \filter_var($eventProperties['preventOverlapping'] ?? false, FILTER_VALIDATE_BOOLEAN), $eventTimezone, $timezoneForComparisons, ...$nextRuns ); } /** * @param string[] $properties * * @return array */ private function getEventProperties(Event $event, array $properties): array { $propertiesExtractor = function () use ($properties, $event): array { $values = []; foreach ($properties as $property) { if (!\property_exists($event, $property)) { $class = $event::class; throw new \RuntimeException("Property '{$property}' doesn't exists in '{$class}' class."); } $values[$property] = $this->{$property}; } return $values; }; return $propertiesExtractor->bindTo($event, Event::class)(); } } ================================================ FILE: src/Application/Query/TaskInformation/TaskInformationView.php ================================================ nextRuns = $nextRuns; } public function command(): string|object { return $this->command; } public function description(): string { return $this->description; } public function cronExpression(): string { return $this->cronExpression; } public function timeZone(): ?\DateTimeZone { return $this->timeZone; } public function configTimeZone(): \DateTimeZone { return $this->configTimeZone; } /** @return \DateTimeImmutable[] */ public function nextRuns(): array { return $this->nextRuns; } public function preventOverlapping(): bool { return $this->preventOverlapping; } } ================================================ FILE: src/Application/Service/ClosureSerializerInterface.php ================================================ cacheDirectoryFactory = new CacheDirectoryFactory(); $this->envFlags = new EnvFlags(); $this->initializeContainer(); $this->registerDeprecationHandler(); foreach (self::COMMANDS as $commandClass) { /** @var Command $command */ $command = $this->container ->get($commandClass) ; // @phpstan-ignore function.alreadyNarrowedType (backward compatibility with Symfony < 7.4) if (\method_exists($this, 'addCommand')) { $this->addCommand($command); } else { $this->add($command); } } } public function run(?InputInterface $input = null, ?OutputInterface $output = null): int { if (null === $output) { /** @var OutputInterface $outputObject */ $outputObject = $this->container ->get(OutputInterface::class); $output = $outputObject; } if (null === $input) { /** @var InputInterface $inputObject */ $inputObject = $this->container ->get(InputInterface::class); $input = $inputObject; } return parent::run($input, $output); } private function initializeContainer(): void { $containerCacheDirWritable = $this->createBaseCacheDirectory(); $isContainerDebugEnabled = $this->envFlags ->isContainerDebugEnabled(); if ($containerCacheDirWritable) { $class = 'CrunzContainer'; $baseClass = 'Container'; $cachePath = Path::create( [ $this->getContainerCacheDir(), "{$class}.php", ] ); $cache = new ConfigCache($cachePath->toString(), $isContainerDebugEnabled); if (!$cache->isFresh()) { $containerBuilder = $this->buildContainer(); $containerBuilder->compile(); $this->dumpContainer( $cache, $containerBuilder, $class, $baseClass ); } require_once $cache->getPath(); $this->container = new $class(); return; } $containerBuilder = $this->buildContainer(); $containerBuilder->compile(); $this->container = $containerBuilder; } /** * @return ContainerBuilder * * @throws \Exception */ private function buildContainer() { $containerBuilder = new ContainerBuilder(); $configDir = Path::create( [ __DIR__, '..', 'config', ] ); $phpLoader = new PhpFileLoader($containerBuilder, new FileLocator($configDir->toString())); $phpLoader->load('services.php'); return $containerBuilder; } private function dumpContainer( ConfigCache $cache, ContainerBuilder $container, string $class, string $baseClass, ): void { $dumper = new PhpDumper($container); /** @var string $content */ $content = $dumper->dump( [ 'class' => $class, 'base_class' => $baseClass, 'file' => $cache->getPath(), ] ); $cache->write($content, $container->getResources()); } /** * @return bool */ private function createBaseCacheDirectory() { $baseCacheDir = $this->getBaseCacheDir(); if (!\is_dir($baseCacheDir)) { $makeDirResult = \mkdir( $this->getBaseCacheDir(), 0777, true ); return $makeDirResult && \is_dir($baseCacheDir) && \is_writable($baseCacheDir) ; } return \is_writable($baseCacheDir); } private function getBaseCacheDir(): string { return $this->cacheDirectoryFactory->generate()->toString(); } /** * @return string */ private function getContainerCacheDir() { $containerCacheDir = Path::create( [ $this->getBaseCacheDir(), \get_current_user(), $this->getVersion(), ] ); return $containerCacheDir->toString(); } private function registerDeprecationHandler(): void { $isDeprecationHandlerEnabled = $this->envFlags ->isDeprecationHandlerEnabled(); if (!$isDeprecationHandlerEnabled) { return; } /** @var SymfonyStyle $io */ $io = $this->container ->get(SymfonyStyle::class); \set_error_handler( static function ( int $errorNumber, string $errorString, string $file, int $line, ) use ($io): bool { $io->block( "{$errorString} File {$file}, line {$line}", 'Deprecation', 'bg=yellow;fg=black', ' ', true ); return true; }, E_USER_DEPRECATED ); } } ================================================ FILE: src/CacheDirectoryFactory/CacheDirectoryFactory.php ================================================ */ private $config; public function __construct( private readonly ConfigurationParserInterface $configurationParser, private readonly FilesystemInterface $filesystem, ) { } /** * Return a parameter based on a key. */ public function get(string $key, mixed $default = null): mixed { if (null === $this->config) { $this->config = $this->configurationParser ->parseConfig(); } if (\array_key_exists($key, $this->config)) { return $this->config[$key]; } $parts = \explode('.', $key); $value = $this->config; foreach ($parts as $part) { if (!\is_array($value) || !\array_key_exists($part, $value)) { return $default; } $value = $value[$part]; } return $value; } /** * Set a parameter based on key/value. */ public function withNewEntry(string $key, mixed $value): ConfigurationInterface { $newConfiguration = clone $this; if (null === $newConfiguration->config) { $newConfiguration->config = $newConfiguration->configurationParser ->parseConfig(); } $parts = \explode('.', $key); if (\count($parts) > 1) { if (\array_key_exists($parts[0], $newConfiguration->config) && \is_array($newConfiguration->config[$parts[0]])) { $newConfiguration->config[$parts[0]][$parts[1]] = $value; } else { $newConfiguration->config[$parts[0]] = [$parts[1] => $value]; } } else { $newConfiguration->config[$key] = $value; } return $newConfiguration; } public function getSourcePath(): string { $sourcePath = Path::create( [ $this->filesystem ->getCwd(), $this->get('source', 'tasks'), ] ); return $sourcePath->toString(); } } ================================================ FILE: src/Configuration/ConfigurationParser.php ================================================ configFilePath(); $parsedConfig = $this->fileParser ->parse($configFile); $configFileParsed = true; } catch (ConfigFileNotExistsException $exception) { $this->consoleLogger ->debug("Config file not found, exception message: '{$exception->getMessage()}'."); } catch (ConfigFileNotReadableException $exception) { $this->consoleLogger ->debug("Config file is not readable, exception message: '{$exception->getMessage()}'."); } if (false === $configFileParsed) { $this->consoleLogger ->verbose('Unable to find/parse config file, fallback to default values.'); } else { $this->consoleLogger ->verbose("Using config file {$configFile}."); } return $this->definitionProcessor ->processConfiguration( $this->configurationDefinition, $parsedConfig ); } /** @throws ConfigFileNotExistsException */ private function configFilePath(): string { $cwd = $this->filesystem ->getCwd(); $configPath = Path::fromStrings($cwd ?? '', ConfigGeneratorCommand::CONFIG_FILE_NAME)->toString(); $configExists = $this->filesystem ->fileExists($configPath); if ($configExists) { return $configPath; } throw new ConfigFileNotExistsException( \sprintf( 'Unable to find config file "%s".', $configPath ) ); } } ================================================ FILE: src/Configuration/ConfigurationParserInterface.php ================================================ */ public function parseConfig(): array; } ================================================ FILE: src/Configuration/Definition.php ================================================ getRootNode(); $rootNode ->children() ->scalarNode('source') ->cannotBeEmpty() ->info('path to the tasks directory' . PHP_EOL) ->end() ->scalarNode('suffix') ->defaultValue('Tasks.php') ->info('The suffix for filenames' . PHP_EOL) ->end() ->scalarNode('timezone') ->info('Timezone used to calculate task run date') ->end() ->booleanNode('timezone_log') ->defaultFalse() ->info('Whether configured "timezone" will be used for logs') ->end() ->scalarNode('logger_factory') ->defaultValue(PsrStreamLoggerFactory::class) ->cannotBeEmpty() ->info("Class name implementing 'LoggerFactoryInterface'. Use it to provider your own logger.") ->end() ->booleanNode('log_errors') ->defaultFalse() ->info('Flag for logging errors' . PHP_EOL) ->end() ->scalarNode('errors_log_file') ->defaultValue('/dev/null') ->info('Path to errors log' . PHP_EOL) ->end() ->booleanNode('log_output') ->defaultFalse() ->info('Flag for logging output' . PHP_EOL) ->end() ->scalarNode('output_log_file') ->defaultValue('/dev/null') ->info('Path to output logs' . PHP_EOL) ->end() ->scalarNode('log_allow_line_breaks') ->defaultFalse() ->info('Flag for line breaks in logs' . PHP_EOL) ->end() ->scalarNode('log_ignore_empty_context') ->defaultFalse() ->info('Flag for empty context in logs' . PHP_EOL) ->end() ->scalarNode('email_output') ->defaultFalse() ->info('Email the event\'s output' . PHP_EOL) ->end() ->scalarNode('email_errors') ->defaultFalse() ->info('Notify by email in case of an error' . PHP_EOL) ->end() ->arrayNode('mailer') ->children() ->scalarNode('transport') ->info('The type the Swift transporter' . PHP_EOL) ->end() ->arrayNode('recipients') ->prototype('scalar')->end() ->info('List of the email recipients' . PHP_EOL) ->end() ->scalarNode('sender_name') ->info('The sender name' . PHP_EOL) ->end() ->scalarNode('sender_email') ->info('The sender email' . PHP_EOL) ->end() ->end() ->end() ->arrayNode('smtp') ->children() ->scalarNode('host') ->info('SMTP host' . PHP_EOL) ->end() ->scalarNode('port') ->info('SMTP port' . PHP_EOL) ->end() ->scalarNode('username') ->info('SMTP username' . PHP_EOL) ->end() ->scalarNode('password') ->info('SMTP password' . PHP_EOL) ->end() ->scalarNode('encryption') ->info('SMTP encryption' . PHP_EOL) ->end() ->end() ->end() ->end() ; return $treeBuilder; } } ================================================ FILE: src/Configuration/FileParser.php ================================================ * * @throws ConfigFileNotExistsException * @throws ConfigFileNotReadableException */ public function parse(string $configPath): array { if (!\file_exists($configPath)) { throw ConfigFileNotExistsException::fromFilePath($configPath); } if (!\is_readable($configPath)) { throw ConfigFileNotReadableException::fromFilePath($configPath); } $yamlParser = $this->yamlParser; $configContent = \file_get_contents($configPath); if (false === $configContent) { throw ConfigFileNotReadableException::fromFilePath($configPath); } return [$yamlParser::parse($configContent)]; } } ================================================ FILE: src/Console/Command/Command.php ================================================ */ protected $arguments; /** @var array */ protected $options; /** * Input object. * * @var \Symfony\Component\Console\Input\InputInterface */ protected $input; /** * output object. * * @var \Symfony\Component\Console\Output\OutputInterface */ protected $output; } ================================================ FILE: src/Console/Command/ConfigGeneratorCommand.php ================================================ setName('publish:config') ->setDescription("Generates a config file within the project's root directory.") ->setHelp("This generates a config file in YML format within the project's root directory.") ; } protected function execute(InputInterface $input, OutputInterface $output): int { $symfonyStyleIo = new SymfonyStyle($input, $output); $cwd = $this->filesystem ->getCwd(); $path = Path::create([$cwd, self::CONFIG_FILE_NAME])->toString(); $destination = \realpath($path) ?: $path; $configExists = $this->filesystem ->fileExists($destination) ; $output->writeln( "Destination config file: '{$destination}'.", OutputInterface::VERBOSITY_VERBOSE ); if ($configExists) { $output->writeln( "The configuration file already exists at '{$destination}'." ); return 0; } $projectRoot = $this->filesystem ->projectRootDirectory(); $srcPath = Path::fromStrings( $projectRoot, 'resources', 'config', self::CONFIG_FILE_NAME ); $src = $srcPath->toString(); $output->writeln( "Source config file: '{$src}'.", OutputInterface::VERBOSITY_VERBOSE ); $defaultTimezone = $this->askForTimezone($symfonyStyleIo); $output->writeln( "Provided timezone: '{$defaultTimezone}'.", OutputInterface::VERBOSITY_VERBOSE ); $this->updateTimezone( $destination, $src, $defaultTimezone ); $output->writeln('The configuration file was generated successfully.'); return 0; } /** * @return string */ protected function askForTimezone(SymfonyStyle $symfonyStyleIo) { $defaultTimezone = $this->timezoneProvider ->defaultTimezone() ->getName() ; $question = new Question( 'Please provide default timezone for task run date calculations', $defaultTimezone ); $question->setAutocompleterValues(\DateTimeZone::listIdentifiers()); $question->setValidator( static function ($answer) { try { new \DateTimeZone($answer); } catch (\Exception) { throw new \Exception("Timezone '{$answer}' is not valid. Please provide valid timezone."); } return $answer; } ); return $symfonyStyleIo->askQuestion($question); } private function updateTimezone( string $destination, string $src, string $timezone, ): void { $this->symfonyFilesystem ->dumpFile( $destination, \str_replace( 'timezone: ~', "timezone: {$timezone}", $this->filesystem ->readContent($src) ) ) ; } } ================================================ FILE: src/Console/Command/ScheduleListCommand.php ================================================ setName('schedule:list') ->setDescription('Displays the list of scheduled tasks.') ->setDefinition( [ new InputArgument( 'source', InputArgument::OPTIONAL, 'The source directory for collecting the tasks.', $this->configuration ->getSourcePath() ), ] ) ->addOption( 'format', 'f', InputOption::VALUE_REQUIRED, "Tasks list format, possible formats: \"{$possibleFormats}\".", self::FORMAT_TEXT, ) ; } /** * @throws WrongTaskInstanceException */ protected function execute(InputInterface $input, OutputInterface $output): int { /** @var string $source */ $source = $input->getArgument('source'); $format = $this->resolveFormat($input); $tasks = $this->tasks($source); if (!\count($tasks)) { $output->writeln('No task found!'); return 0; } $this->printList( $output, $tasks, $format, ); return 0; } /** * @return array< * int, * array{ * number: int, * task: string, * expression: string, * command: string, * }, * > */ private function tasks(string $source): array { /** @var \SplFileInfo[] $tasks */ $tasks = $this->taskCollection ->all($source) ; $schedules = $this->taskLoader ->load(...\array_values($tasks)) ; $tasksList = []; $number = 0; foreach ($schedules as $schedule) { $events = $schedule->events(); foreach ($events as $event) { $tasksList[] = [ 'number' => ++$number, 'task' => $event->description ?? '', 'expression' => $event->getExpression(), 'command' => $event->getCommandForDisplay(), ]; } } return $tasksList; } private function resolveFormat(InputInterface $input): string { /** @var string $format */ $format = $input->getOption('format'); $isValidFormat = \in_array( $format, self::FORMATS, true, ); if (false === $isValidFormat) { throw new CrunzException("Format '{$format}' is not supported."); } return $format; } /** * @param array< * int, * array{ * number: int, * task: string, * expression: string, * command: string, * }, * > $tasks */ private function printList( OutputInterface $output, array $tasks, string $format, ): void { switch ($format) { case self::FORMAT_TEXT: $table = new Table($output); $table->setHeaders( [ '#', 'Task', 'Expression', 'Command to Run', ] ); foreach ($tasks as $task) { $table->addRow($task); } $table->render(); break; case self::FORMAT_JSON: $output->writeln( \json_encode( $tasks, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, ), ); break; default: throw new CrunzException("Unable to print list in format '{$format}'."); } } } ================================================ FILE: src/Console/Command/ScheduleRunCommand.php ================================================ setName('schedule:run') ->setDescription('Starts the event runner.') ->setDefinition( [ new InputArgument( 'source', InputArgument::OPTIONAL, 'The source directory for collecting the task files.', $this->configuration ->getSourcePath() ), ] ) ->addOption( 'force', 'f', InputOption::VALUE_NONE, 'Run all tasks regardless of configured run time.' ) ->addOption( 'task', 't', InputOption::VALUE_REQUIRED, 'Which task to run. Provide task number from schedule:list command.', null ) ->setHelp('This command starts the Crunz event runner.'); } /** * @throws WrongTaskInstanceException */ protected function execute(InputInterface $input, OutputInterface $output): int { $this->arguments = $input->getArguments(); $this->options = $input->getOptions(); $task = $this->options['task']; /** @var string $source */ $source = $input->getArgument('source') ?? ''; /** @var \SplFileInfo[] $files */ $files = $this->taskCollection ->all($source) ; if (!\count($files)) { $output->writeln('No task found! Please check your source path.'); return 0; } // List of schedules $schedules = $this->taskLoader ->load(...\array_values($files)) ; $tasksTimezone = $this->taskTimezone ->timezoneForComparisons() ; // Is specified task should be invoked? if (\is_string($task)) { $schedules = $this->scheduleFactory ->singleTaskSchedule(TaskNumber::fromString($task), ...$schedules); } $forceRun = \filter_var($this->options['force'] ?? false, FILTER_VALIDATE_BOOLEAN); $schedules = \array_map( static function (Schedule $schedule) use ($tasksTimezone, $forceRun) { if (false === $forceRun) { // We keep the events which are due and dismiss the rest. $schedule->events( $schedule->dueEvents( $tasksTimezone ) ); } return $schedule; }, $schedules ); $schedules = \array_filter( $schedules, static fn (Schedule $schedule): bool => \count($schedule->events()) > 0 ); if (!\count($schedules)) { $output->writeln('No event is due!'); return 0; } // Running the events $this->eventRunner ->handle($output, $schedules) ; return 0; } } ================================================ FILE: src/Console/Command/TaskGeneratorCommand.php ================================================ */ final public const DEFAULTS = [ 'frequency' => 'everyThirtyMinutes', 'constraint' => 'weekdays', 'in' => 'path/to/your/command', 'run' => 'command/to/execute', 'description' => 'Task description', 'type' => 'basic', ]; /** * Stub content. * * @var string */ protected $stub; public function __construct( private readonly ConfigurationInterface $config, private readonly FilesystemInterface $filesystem, ) { parent::__construct(); } /** * Configures the current command. */ protected function configure(): void { $this ->setName('make:task') ->setDescription('Generates a task file with one task.') ->setDefinition( [ new InputArgument( 'taskfile', InputArgument::REQUIRED, 'The task file name' ), new InputOption( 'frequency', 'f', InputOption::VALUE_OPTIONAL, "The task's frequency", self::DEFAULTS['frequency'] ), new InputOption( 'constraint', 'c', InputOption::VALUE_OPTIONAL, "The task's constraint", self::DEFAULTS['constraint'] ), new InputOption( 'in', 'i', InputOption::VALUE_OPTIONAL, "The command's path", self::DEFAULTS['in'] ), new InputOption( 'run', 'r', InputOption::VALUE_OPTIONAL, "The task's command", self::DEFAULTS['run'] ), new InputOption( 'description', 'd', InputOption::VALUE_OPTIONAL, "The task's description", self::DEFAULTS['description'] ), new InputOption( 'type', 't', InputOption::VALUE_OPTIONAL, 'The task type', self::DEFAULTS['type'] ), ] ) ->setHelp('This command makes a task file skeleton.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $this->input = $input; $this->output = $output; $this->arguments = $input->getArguments(); $this->options = $input->getOptions(); $this->stub = $this->getStub(); if ($this->stub) { $this ->replaceFrequency() ->replaceConstraint() ->replaceCommand() ->replacePath() ->replaceDescription() ; } if ($this->save()) { $output->writeln('The task file generated successfully'); } else { $output->writeln('There was a problem when generating the file. Please check your command.'); } return 0; } /** * Save the generate task skeleton into a file. * * @return bool */ protected function save() { $filename = Path::create([$this->outputPath(), $this->outputFile()]); return (bool) \file_put_contents($filename->toString(), $this->stub); } /** * Ask a question. * * @param string $question * * @return ?string */ protected function ask($question) { $helper = $this->getHelper('question'); $question = new Question("{$question}"); return $helper->ask($this->input, $this->output, $question); } /** * Return the output path. * * @return string */ protected function outputPath() { $source = $this->config ->getSourcePath() ; $destination = $this->ask('Where do you want to save the file? (Press enter for the current directory)'); $outputPath = $destination ?? $source; if (!\file_exists($outputPath)) { \mkdir($outputPath, 0744, true); } return $outputPath; } /** * Populate the output filename. * * @return string */ protected function outputFile() { /** @var string $suffix */ $suffix = $this->config ->get('suffix') ; /** @var string $taskFile */ $taskFile = $this->arguments['taskfile']; return \preg_replace('/Tasks|\.php$/', '', $taskFile) . $suffix; } /** * Get the task stub. * * @return string */ protected function getStub() { $projectRootDirectory = $this->filesystem ->projectRootDirectory(); $path = Path::fromStrings( $projectRootDirectory, 'src', 'Stubs', \ucfirst($this->type() . 'Task.php') ); return $this->filesystem ->readContent($path->toString()); } /** * Get the task type. * * @return string */ protected function type() { return $this->options['type']; } /** * Replace frequency. */ protected function replaceFrequency(): self { $this->stub = \str_replace('DummyFrequency', \rtrim($this->options['frequency'], '()'), $this->stub); return $this; } /** * Replace constraint. */ protected function replaceConstraint(): self { $this->stub = \str_replace('DummyConstraint', \rtrim($this->options['constraint'], '()'), $this->stub); return $this; } protected function replaceCommand(): self { $run = $this->optionString('run'); $this->stub = \str_replace('DummyCommand', $run, $this->stub); return $this; } protected function replacePath(): self { $in = $this->optionString('in'); $this->stub = \str_replace('DummyPath', $in, $this->stub); return $this; } protected function replaceDescription(): self { $description = $this->optionString('description'); $this->stub = \str_replace('DummyDescription', $description, $this->stub); return $this; } private function optionString(string $name): string { $option = $this->options[$name] ?? throw new \RuntimeException("Missing option '{$name}'."); if (false === \is_string($option)) { throw new \RuntimeException("Option must be of type 'string'."); } return $option; } } ================================================ FILE: src/EnvFlags/EnvFlags.php ================================================ */ protected $fieldsPosition = [ 'minute' => 1, 'hour' => 2, 'day' => 3, 'month' => 4, 'week' => 5, ]; /** * Indicates if the command should not overlap itself. */ private bool $preventOverlapping = false; /** @var ClockInterface */ private static $clock; private static ?ClosureSerializerInterface $closureSerializer = null; /** * The symfony lock factory that is used to acquire locks. If the value is null, but preventOverlapping = true * crunz falls back to filesystem locks. */ private ?LockFactory $lockFactory = null; /** @var string[] */ private array $wholeOutput = []; /** @var Lock */ private $lock; /** @var \Closure[] */ private array $errorCallbacks = []; /** * Create a new event instance. * * @param string|\Closure $command * @param string|int $id */ public function __construct(protected $id, $command) { $this->command = $command; $this->output = $this->getDefaultOutput(); } /** * Change the current working directory. * * @param string $directory * * @return self */ public function in($directory) { $this->cwd = $directory; return $this; } /** * Determine if the event's output is sent to null. * * @return bool */ public function nullOutput() { return 'NUL' === $this->output || '/dev/null' === $this->output; } /** * Build the command string. * * @return string */ public function buildCommand() { $command = ''; if ($this->cwd) { if ($this->user) { $command .= $this->sudo($this->user); } // Support changing drives in Windows $cdParameter = $this->isWindows() ? '/d ' : ''; $andSign = $this->isWindows() ? ' &' : ';'; $command .= "cd {$cdParameter}{$this->cwd}{$andSign} "; } if ($this->user) { $command .= $this->sudo($this->user); } $command .= \is_string($this->command) ? $this->command : $this->serializeClosure($this->command) ; return \trim($command, '& '); } /** * Determine whether the passed value is a closure or not. * * @return bool */ public function isClosure() { return \is_object($this->command) && ($this->command instanceof \Closure); } /** * Determine if the given event should run based on the Cron expression. * * @return bool */ public function isDue(\DateTimeZone $timeZone) { return $this->expressionPasses($timeZone) && $this->filtersPass($timeZone); } /** * Determine if the filters pass for the event. * * @return bool */ public function filtersPass(\DateTimeZone $timeZone) { $invoker = new Invoker(); foreach ($this->filters as $callback) { if (!$invoker->call($callback)) { return false; } } foreach ($this->rejects as $callback) { if ($invoker->call($callback, [$timeZone])) { return false; } } return true; } /** @return string */ public function wholeOutput() { return \implode('', $this->wholeOutput); } /** * Start the event execution. * * @return int */ public function start() { $command = $this->buildCommand(); $process = Process::fromStringCommand($command); $this->setProcess($process); $this->getProcess()->start( function ($type, $content): void { $this->wholeOutput[] = $content; } ); if ($this->preventOverlapping) { $this->lock(); } /** @var int $pid */ $pid = $this->getProcess() ->getPid(); return $pid; } /** * The Cron expression representing the event's frequency. * * @throws TaskException */ public function cron(string $expression): self { $parts = \preg_split( '/\s/', $expression, -1, PREG_SPLIT_NO_EMPTY ); $parts = false === $parts ? [] : $parts ; if (\count($parts) > 5) { throw new TaskException("Expression '{$expression}' has more than five parts and this is not allowed."); } $this->expression = $expression; return $this; } /** * Schedule the event to run hourly. */ public function hourly(): self { return $this->hourlyAt(0); } public function hourlyAt(int $minute): self { if ($minute < 0) { throw new CrunzException("Minute cannot be lower than '0'."); } if ($minute > 59) { throw new CrunzException("Minute cannot be greater than '59'."); } return $this->cron("{$minute} * * * *"); } /** * Schedule the event to run daily. */ public function daily(): self { return $this->cron('0 0 * * *'); } /** * Schedule the event to run on a certain date. * * @param string $date * * @return $this */ public function on($date) { $parsedDate = \date_parse($date); if (false === $parsedDate) { $parsedDate = []; } $segments = \array_intersect_key($parsedDate, $this->fieldsPosition); if ($parsedDate['year']) { $this->skip(static fn () => (int) \date('Y') !== $parsedDate['year']); } foreach ($segments as $key => $value) { if (false !== $value) { $this->spliceIntoPosition($this->fieldsPosition[$key], (string) $value); } } return $this; } /** * Schedule the command at a given time. * * @param string $time */ public function at($time): self { return $this->dailyAt($time); } /** * Schedule the event to run daily at a given time (10:00, 19:30, etc). * * @param string $time */ public function dailyAt($time): self { $segments = \explode(':', $time); $firstSegment = (int) $segments[0]; $secondSegment = \count($segments) > 1 ? (int) $segments[1] : '0' ; return $this ->spliceIntoPosition(2, (string) $firstSegment) ->spliceIntoPosition(1, (string) $secondSegment) ; } /** * Set Working period. * * @param string $from * @param string $to * * @return self */ public function between($from, $to) { return $this->from($from) ->to($to); } /** * Check if event should be on. * * @param string $datetime * * @return self */ public function from($datetime) { $this->from = $datetime; return $this->skip( fn (\DateTimeZone $timeZone) => $this->notYet($datetime, $timeZone) ); } /** * Check if event should be off. * * @param string $datetime * * @return self */ public function to($datetime) { $this->to = $datetime; return $this->skip( fn (\DateTimeZone $timeZone) => $this->past($datetime, $timeZone), ); } /** * Schedule the event to run twice daily. * * @param int $first * @param int $second */ public function twiceDaily($first = 1, $second = 13): self { $hours = $first . ',' . $second; return $this ->spliceIntoPosition(1, '0') ->spliceIntoPosition(2, $hours) ; } /** * Schedule the event to run only on weekdays. */ public function weekdays(): self { return $this->spliceIntoPosition(5, '1-5'); } /** * Schedule the event to run only on Mondays. */ public function mondays(): self { return $this->days(1); } /** * Schedule the event to run only on Tuesdays. */ public function tuesdays(): self { return $this->days(2); } /** * Schedule the event to run only on Wednesdays. */ public function wednesdays(): self { return $this->days(3); } /** * Schedule the event to run only on Thursdays. */ public function thursdays(): self { return $this->days(4); } /** * Schedule the event to run only on Fridays. */ public function fridays(): self { return $this->days(5); } /** * Schedule the event to run only on Saturdays. */ public function saturdays(): self { return $this->days(6); } /** * Schedule the event to run only on Sundays. */ public function sundays(): self { return $this->days(0); } /** * Schedule the event to run weekly. */ public function weekly(): self { return $this->cron('0 0 * * 0'); } /** * Schedule the event to run weekly on a given day and time. * * @param string $time */ public function weeklyOn(int|string $day, $time = '0:0'): self { $this->dailyAt($time); return $this->spliceIntoPosition(5, (string) $day); } /** * Schedule the event to run monthly. */ public function monthly(): self { return $this->cron('0 0 1 * *'); } /** * Schedule the event to run quarterly. */ public function quarterly(): self { return $this->cron('0 0 1 */3 *'); } /** * Schedule the event to run yearly. */ public function yearly(): self { return $this->cron('0 0 1 1 *'); } /** * Set the days of the week the command should run on. */ public function days(mixed $days): self { $days = \is_array($days) ? $days : \func_get_args(); return $this->spliceIntoPosition(5, \implode(',', $days)); } /** * Set hour for the cron job. */ public function hour(mixed $value): self { $value = \is_array($value) ? $value : \func_get_args(); return $this->spliceIntoPosition(2, \implode(',', $value)); } /** * Set minute for the cron job. */ public function minute(mixed $value): self { $value = \is_array($value) ? $value : \func_get_args(); return $this->spliceIntoPosition(1, \implode(',', $value)); } /** * Set hour for the cron job. */ public function dayOfMonth(mixed $value): self { $value = \is_array($value) ? $value : \func_get_args(); return $this->spliceIntoPosition(3, \implode(',', $value)); } /** * Set hour for the cron job. */ public function month(mixed $value): self { $value = \is_array($value) ? $value : \func_get_args(); return $this->spliceIntoPosition(4, \implode(',', $value)); } /** * Set hour for the cron job. */ public function dayOfWeek(mixed $value): self { $value = \is_array($value) ? $value : \func_get_args(); return $this->spliceIntoPosition(5, \implode(',', $value)); } /** * Set the timezone the date should be evaluated on. * * @return $this */ public function timezone(\DateTimeZone|string $timezone) { $this->timezone = $timezone; return $this; } /** * Set which user the command should run as. * * @param string $user * * @return $this */ public function user($user) { if ($this->isWindows()) { throw new NotImplementedException('Changing user on Windows is not implemented.'); } $this->user = $user; return $this; } /** * Do not allow the event to overlap each other. * * By default, the lock is acquired through file system locks. Alternatively, you can pass a symfony lock store * that will be responsible for the locking. * * @param PersistingStoreInterface|object $store * * @return $this */ public function preventOverlapping(?object $store = null) { if (null !== $store && !($store instanceof PersistingStoreInterface)) { $expectedClass = PersistingStoreInterface::class; $actualClass = $store::class; throw new \RuntimeException( "Instance of '{$expectedClass}' is expected, '{$actualClass}' provided" ); } $lockStore = $store ?: $this->createDefaultLockStore(); $this->preventOverlapping = true; $this->lockFactory = new LockFactory($lockStore); // Skip the event if it's locked (processing) $this->skip(function () { $lock = $this->createLockObject(); $lock->acquire(); return !$lock->isAcquired(); }); $releaseCallback = function (): void { $this->releaseLock(); }; // Delete the lock file when the event is completed $this->after($releaseCallback); // Or on error $this->addErrorCallback($releaseCallback); return $this; } /** * Register a callback to further filter the schedule. * * @return $this */ public function when(\Closure $callback) { $this->filters[] = $callback; return $this; } /** * Register a callback to further filter the schedule. * * @return $this */ public function skip(\Closure $callback) { $this->rejects[] = $callback; return $this; } /** * Send the output of the command to a given location. * * @param string $location * @param bool $append * * @return $this */ public function sendOutputTo($location, $append = false) { $this->output = $location; $this->shouldAppendOutput = $append; return $this; } /** * Append the output of the command to a given location. * * @param string $location * * @return $this */ public function appendOutputTo($location) { return $this->sendOutputTo($location, true); } /** * Register a callback to be called before the operation. * * @return $this */ public function before(\Closure $callback) { $this->beforeCallbacks[] = $callback; return $this; } /** * Register a callback to be called after the operation. * * @return $this */ public function after(\Closure $callback) { return $this->then($callback); } /** * Register a callback to be called after the operation. * * @return $this */ public function then(\Closure $callback) { $this->afterCallbacks[] = $callback; return $this; } /** * Set the human-friendly description of the event. * * @param string $description * * @return $this */ public function name($description) { return $this->description($description); } /** * Return the event's process. * * @return Process $process */ public function getProcess() { return $this->process; } /** * Set the human-friendly description of the event. * * @param string $description * * @return $this */ public function description($description) { $this->description = $description; return $this; } /** * Another way to the frequency of the cron job. * * @param string $unit * @param float|int|null $value */ public function every($unit = null, $value = null): self { if (null === $unit || !isset($this->fieldsPosition[$unit])) { return $this; } $value = (1 === (int) $value) ? '*' : '*/' . $value; return $this->spliceIntoPosition($this->fieldsPosition[$unit], $value) ->applyMask($unit); } /** * Return the event's command. */ public function getId(): string|int { return $this->id; } /** * Get the summary of the event for display. * * @return string */ public function getSummaryForDisplay() { if (\is_string($this->description)) { return $this->description; } return $this->buildCommand(); } /** * Get the command for display. * * @return string */ public function getCommandForDisplay() { return $this->isClosure() ? 'object(Closure)' : $this->buildCommand(); } /** * Get the Cron expression for the event. * * @return string */ public function getExpression() { return $this->expression; } /** * Get the 'from' configuration for the event if present. */ public function getFrom(): \DateTime|string|null { return $this->from; } /** * Get the 'to' configuration for the event if present. */ public function getTo(): \DateTime|string|null { return $this->to; } /** * Set the event's command. * * @param string $command * * @return $this */ public function setCommand($command) { $this->command = $command; return $this; } /** * Return the event's command. */ public function getCommand(): string|\Closure { return $this->command; } /** * Return the current working directory. * * @return string */ public function getWorkingDirectory() { return $this->cwd; } /** * Return event's full output. * * @return string|null */ public function getOutputStream() { return $this->outputStream; } /** * Return all registered before callbacks. * * @return \Closure[] */ public function beforeCallbacks() { return $this->beforeCallbacks; } /** * Return all registered after callbacks. * * @return \Closure[] */ public function afterCallbacks() { return $this->afterCallbacks; } /** @return \Closure[] */ public function errorCallbacks() { return $this->errorCallbacks; } /** * If this event is prevented from overlapping, this method should be called regularly to refresh the lock. */ public function refreshLock(): void { if (!$this->preventOverlapping) { return; } $lock = $this->createLockObject(); $remainingLifetime = $lock->getRemainingLifetime(); // Lock will never expire if (null === $remainingLifetime) { return; } $lockRefreshNeeded = $remainingLifetime < self::LOCK_REFRESH_THRESHOLD; if ($lockRefreshNeeded) { $lock->refresh(); } } public function everyMinute(): self { return $this->cron('* * * * *'); } public function everyTwoMinutes(): self { return $this->cron('*/2 * * * *'); } public function everyThreeMinutes(): self { return $this->cron('*/3 * * * *'); } public function everyFourMinutes(): self { return $this->cron('*/4 * * * *'); } public function everyFiveMinutes(): self { return $this->cron('*/5 * * * *'); } public function everyTenMinutes(): self { return $this->cron('*/10 * * * *'); } public function everyFifteenMinutes(): self { return $this->cron('*/15 * * * *'); } public function everyThirtyMinutes(): self { return $this->cron('*/30 * * * *'); } public function everyTwoHours(): self { return $this->cron('0 */2 * * *'); } public function everyThreeHours(): self { return $this->cron('0 */3 * * *'); } public function everyFourHours(): self { return $this->cron('0 */4 * * *'); } public function everySixHours(): self { return $this->cron('0 */6 * * *'); } /** * Get the symfony lock object for the task. * * @return Lock */ protected function createLockObject() { $this->checkLockFactory(); if (null === $this->lock && null !== $this->lockFactory) { $this->lock = $this->lockFactory ->createLock($this->lockKey(), self::LOCK_TTL); } return $this->lock; } /** * Release the lock after the command completed. */ protected function releaseLock(): void { $this->checkLockFactory(); $lock = $this->createLockObject(); $lock->release(); } /** * Get the default output depending on the OS. * * @return string */ protected function getDefaultOutput() { return (DIRECTORY_SEPARATOR === '\\') ? 'NUL' : '/dev/null'; } /** * Add sudo to the command. * * @param string $user * * @return string */ protected function sudo($user) { return "sudo -u {$user} "; } /** * Convert closure to an executable command. * * @return string */ protected function serializeClosure(\Closure $closure) { $closure = $this->closureSerializer() ->serialize($closure) ; $serializedClosure = \http_build_query([$closure]); $crunzRoot = CRUNZ_BIN; return \escapeshellarg(PHP_BINARY) . ' ' . \escapeshellarg($crunzRoot) . " closure:run {$serializedClosure}"; } /** * Determine if the Cron expression passes. * * @return bool */ protected function expressionPasses(\DateTimeZone $timeZone) { $now = $this->getClock() ->now(); $now = $now->setTimezone($timeZone); if ($this->timezone) { $taskTimeZone = \is_object($this->timezone) && $this->timezone instanceof \DateTimeZone ? $this->timezone ->getName() : $this->timezone ; $now = $now->setTimezone( new \DateTimeZone( $taskTimeZone ) ); } return CronExpression::factory($this->expression)->isDue($now->format('Y-m-d H:i:s')); } /** * Check if time hasn't arrived. * * @param string $datetime */ protected function notYet($datetime, \DateTimeZone $timeZone): bool { $timeZonedNow = $this->timeZonedNow($timeZone); $testedDateTime = new \DateTimeImmutable($datetime, $timeZone); return $timeZonedNow < $testedDateTime; } /** * Check if the time has passed. * * @param string $datetime */ protected function past($datetime, \DateTimeZone $timeZone): bool { $timeZonedNow = $this->timeZonedNow($timeZone); $testedDateTime = new \DateTimeImmutable($datetime, $timeZone); return $timeZonedNow > $testedDateTime; } /** * Splice the given value into the given position of the expression. * * @param int $position * @param string $value */ protected function spliceIntoPosition($position, $value): self { $segments = \explode(' ', $this->expression); $segments[$position - 1] = $value; return $this->cron(\implode(' ', $segments)); } /** * Mask a cron expression. * * @param string $unit * * @return self */ protected function applyMask($unit) { $cron = \explode(' ', $this->expression); $mask = ['0', '0', '1', '1', '*', '*']; $fpos = $this->fieldsPosition[$unit] - 1; \array_splice($cron, 0, $fpos, \array_slice($mask, 0, $fpos)); return $this->cron(\implode(' ', $cron)); } /** * Lock the event. */ protected function lock(): void { $lock = $this->createLockObject(); $lock->acquire(); } private function addErrorCallback(\Closure $closure): void { $this->errorCallbacks[] = $closure; } /** * Set the event's process. */ private function setProcess(Process $process): void { $this->process = $process; } /** * @return FlockStore * * @throws CrunzException */ private function createDefaultLockStore() { try { $lockPath = Path::create( [ \sys_get_temp_dir(), '.crunz', ] ); $store = new FlockStore($lockPath->toString()); } catch (InvalidArgumentException) { // Fallback to system temp dir $lockPath = Path::create([\sys_get_temp_dir()]); $store = new FlockStore($lockPath->toString()); } return $store; } private function lockKey(): string { if ($this->isClosure()) { /** @var \Closure $closure */ $closure = $this->command; $command = $this->closureSerializer() ->closureCode($closure) ; } else { $command = $this->buildCommand(); } return 'crunz-' . \md5($command); } private function checkLockFactory(): void { if (null === $this->lockFactory) { throw new \BadMethodCallException( 'No lock factory. Please call preventOverlapping() first.' ); } } private function getClock(): ClockInterface { if (null === self::$clock) { self::$clock = new Clock(); } return self::$clock; } private function closureSerializer(): ClosureSerializerInterface { if (null === self::$closureSerializer) { self::$closureSerializer = new LaravelClosureSerializer(); } return self::$closureSerializer; } private function isWindows(): bool { $osCode = \mb_substr( PHP_OS, 0, 3 ); return 'WIN' === $osCode; } private function timeZonedNow(\DateTimeZone $timeZone): \DateTimeImmutable { $clock = $this->getClock(); $now = $clock->now(); return $now->setTimezone($timeZone); } } ================================================ FILE: src/EventRunner.php ================================================ schedules = $schedules; $this->output = $output; foreach ($this->schedules as $schedule) { $this->consoleLogger ->debug("Invoke Schedule's ping before"); $this->pingBefore($schedule); // Running the before-callbacks of the current schedule $this->invoke($schedule->beforeCallbacks()); $events = $schedule->events(); foreach ($events as $event) { $this->start($event); } } // Watch events until they are finished $this->manageStartedEvents(); } protected function start(Event $event): void { $this->logger = $this->loggerFactory ->create() ; // if sendOutputTo or appendOutputTo have been specified if (!$event->nullOutput()) { // if sendOutputTo then truncate the log file if it exists if (!$event->shouldAppendOutput) { $f = @\fopen($event->output, 'r+'); if (false !== $f) { \ftruncate($f, 0); \fclose($f); } } // Create an instance of the Logger specific to the event $event->logger = $this->loggerFactory->createEvent($event->output); } $this->consoleLogger ->debug("Invoke Event's ping before."); $this->pingBefore($event); // Running the before-callbacks $event->outputStream = $this->invoke($event->beforeCallbacks()); $event->start(); } protected function manageStartedEvents(): void { while ($this->schedules) { foreach ($this->schedules as $scheduleKey => $schedule) { $events = $schedule->events(); // 10% chance that refresh will be called $refreshLocks = (\random_int(1, 100) <= 10); /** @var Event $event */ foreach ($events as $eventKey => $event) { if ($refreshLocks) { $event->refreshLock(); } $proc = $event->getProcess(); if ($proc->isRunning()) { continue; } $runStatus = ''; if ($proc->isSuccessful()) { $this->consoleLogger ->debug("Invoke Event's ping after."); $this->pingAfter($event); $runStatus = 'success'; $event->outputStream .= $event->wholeOutput(); $event->outputStream .= $this->invoke($event->afterCallbacks()); $this->handleOutput($event); } else { $runStatus = 'fail'; // Invoke error callbacks $this->invoke($event->errorCallbacks()); // Calling registered error callbacks with an instance of $event as argument $this->invoke($schedule->errorCallbacks(), [$event]); $this->handleError($event); } $id = $event->description ?: $event->getId(); $this->consoleLogger ->debug("Task {$id} status: {$runStatus}."); // Dismiss the event if it's finished $schedule->dismissEvent($eventKey); } // If there's no event left for the Schedule instance, // run the schedule's after-callbacks and remove // the Schedule from list of active schedules. zzzwwscxqqqAAAQ11 if (!\count($schedule->events())) { $this->consoleLogger ->debug("Invoke Schedule's ping after."); $this->pingAfter($schedule); $this->invoke($schedule->afterCallbacks()); unset($this->schedules[$scheduleKey]); } } \usleep(250000); } } /** * @param \Closure[] $callbacks * @param array $parameters * * @return string */ protected function invoke(array $callbacks = [], array $parameters = []) { $output = ''; foreach ($callbacks as $callback) { /** @var string $callResult */ $callResult = $this->invoker->call($callback, $parameters, true); // Invoke the callback with buffering enabled $output .= $callResult; } return $output; } protected function handleOutput(Event $event): void { $logged = false; $logOutput = $this->configuration ->get('log_output') ; if (!$event->nullOutput()) { $event->logger->info($this->formatEventOutput($event)); $logged = true; } if ($logOutput && !$logged) { $this->logger() ->info($this->formatEventOutput($event)) ; $logged = true; } if (!$logged) { $this->display($event->getOutputStream()); } $emailOutput = $this->configuration ->get('email_output') ; if ($emailOutput && !empty($event->getOutputStream())) { $this->mailer->send( 'Crunz: output for event: ' . ($event->description ?? $event->getId()), $this->formatEventOutput($event) ); } } protected function handleError(Event $event): void { $logErrors = $this->configuration ->get('log_errors') ; $emailErrors = $this->configuration ->get('email_errors') ; if ($logErrors) { $this->logger() ->error($this->formatEventError($event)) ; } else { $output = $event->wholeOutput(); $this->output ?->write("{$output}") ; } // Send error as email as configured if ($emailErrors) { $this->mailer->send( 'Crunz: reporting error for event:' . ($event->description ?? $event->getId()), $this->formatEventError($event) ); } } /** @return string */ protected function formatEventOutput(Event $event) { return $event->description . '(' . $event->getCommandForDisplay() . ') ' . PHP_EOL . PHP_EOL . $event->outputStream . PHP_EOL; } /** @return string */ protected function formatEventError(Event $event) { return $event->description . '(' . $event->getCommandForDisplay() . ') ' . PHP_EOL . $event->wholeOutput() . PHP_EOL; } /** @param string|null $output */ protected function display($output): void { $this->output ?->write(\is_string($output) ? $output : '') ; } private function pingBefore(PingableInterface $schedule): void { if (!$schedule->hasPingBefore()) { $this->consoleLogger ->debug('There is no ping before url.'); return; } /** @var non-empty-string $pingBeforeUrl */ $pingBeforeUrl = $schedule->getPingBeforeUrl(); $this->httpClient ->ping($pingBeforeUrl) ; } private function pingAfter(PingableInterface $schedule): void { if (!$schedule->hasPingAfter()) { $this->consoleLogger ->debug('There is no ping after url.'); return; } /** @var non-empty-string $pingAfterUrl */ $pingAfterUrl = $schedule->getPingAfterUrl(); $this->httpClient ->ping($pingAfterUrl) ; } private function logger(): Logger { if (null === $this->logger) { $this->logger = $this->loggerFactory ->create() ; } return $this->logger; } } ================================================ FILE: src/Exception/CrunzException.php ================================================ toString()); $ignored[$path->toString()] = ''; } $directoryIterator = new \RecursiveDirectoryIterator($directoryPath, \FilesystemIterator::SKIP_DOTS); $recursiveIterator = new \RecursiveIteratorIterator( $directoryIterator, \RecursiveIteratorIterator::CHILD_FIRST ); /** @var \SplFileInfo $path */ foreach ($recursiveIterator as $path) { if (\array_key_exists($path->getPathname(), $ignored)) { ++$ignoredCount; continue; } $path->isDir() && !$path->isLink() ? \rmdir($path->getPathname()) : \unlink($path->getPathname()) ; } if (0 === $ignoredCount) { \rmdir($directoryPath); } } public function dumpFile($filePath, $content): void { $directory = \pathinfo($filePath, \PATHINFO_DIRNAME); $this->createDirectory($directory); \file_put_contents($filePath, $content); } public function createDirectory($directoryPath): void { if ($this->fileExists($directoryPath)) { return; } $created = \mkdir( $directoryPath, 0770, true ); if (!$created && !\is_dir($directoryPath)) { throw new \RuntimeException("Directory '{$directoryPath}' was not created."); } } /** * @param string $sourceFile * @param string $targetFile */ public function copy($sourceFile, $targetFile): void { \copy($sourceFile, $targetFile); } public function projectRootDirectory() { if (null === $this->projectRootDir) { $dir = $rootDir = \dirname(__DIR__); $path = Path::fromStrings($dir, 'composer.json'); while (!\file_exists($path->toString())) { if ($dir === \dirname($dir)) { return $this->projectRootDir = $rootDir; } $dir = \dirname($dir); $path = Path::fromStrings($dir, 'composer.json'); } $this->projectRootDir = $dir; } return $this->projectRootDir; } /** * @param string $filePath * * @return string */ public function readContent($filePath) { if (!$this->fileExists($filePath)) { throw new \RuntimeException("File '{$filePath}' doesn't exists."); } $content = \file_get_contents($filePath); if (false === $content) { throw new \RuntimeException("Unable to get contents of file '{$filePath}'."); } return $content; } } ================================================ FILE: src/Filesystem/FilesystemInterface.php ================================================ toString(), $directoryIteratorFlags); $recursiveIterator = new \RecursiveIteratorIterator($directoryIterator); $regexIterator = new \RegexIterator( $recursiveIterator, "/^.+{$quotedSuffix}$/i", \RecursiveRegexIterator::GET_MATCH ); /** @var \SplFileInfo[] $files */ $files = \array_map( static fn (array $file) => new \SplFileInfo(\reset($file)), \iterator_to_array($regexIterator) ); return $files; } } ================================================ FILE: src/Finder/FinderInterface.php ================================================ chooseHttpClient(); $httpClient->ping($url); } /** @throws HttpClientException */ private function chooseHttpClient(): HttpClientInterface { if (null !== $this->httpClient) { return $this->httpClient; } $this->consoleLogger ->debug('Choosing HttpClient implementation.'); if (\function_exists('curl_exec')) { $this->httpClient = $this->curlHttpClient; $this->consoleLogger ->debug('cURL available, use CurlHttpClient.'); return $this->httpClient; } if ('1' === \ini_get('allow_url_fopen')) { $this->httpClient = $this->streamHttpClient; $this->consoleLogger ->debug("'allow_url_fopen' enabled, use StreamHttpClient"); return $this->httpClient; } $this->consoleLogger ->debug('Choosing HttpClient implementation failed.'); throw new HttpClientException( "Unable to choose HttpClient. Enable cURL extension (preferred) or turn on 'allow_url_fopen' in php.ini." ); } } ================================================ FILE: src/HttpClient/HttpClientException.php ================================================ logger ->verbose("Trying to ping {$url}."); $this->httpClient ->ping($url); $this->logger ->verbose("Pinging url: {$url} was successful."); } } ================================================ FILE: src/HttpClient/StreamHttpClient.php ================================================ [ 'user_agent' => 'Crunz StreamHttpClient', 'timeout' => 5, ], ] ); $resource = @\fopen( $url, 'rb', false, $context ); if (false === $resource) { throw new HttpClientException('Ping failed.'); } \fclose($resource); } } ================================================ FILE: src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpression.php ================================================ setTimezone($timeZone) : $now ; $dates = $this->innerCronExpression ->getMultipleRunDates($total, $timeZoneNow) ; return \array_map( static fn (\DateTime $runDate): \DateTimeImmutable => \DateTimeImmutable::createFromMutable($runDate), $dates ); } } ================================================ FILE: src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpressionFactory.php ================================================ extractWrapper($serializedClosure); return $wrapper->getClosure(); } public function closureCode(\Closure $closure): string { $reflector = new ReflectionClosure($closure); return $reflector->getCode(); } private function extractWrapper(string $serializedClosure): SerializableClosure { return \unserialize( $serializedClosure, ['allowed_classes' => true] ); } } ================================================ FILE: src/Infrastructure/Psr/Logger/EnabledLoggerDecorator.php ================================================ configuration ->get('log_output') ; break; case LogLevel::ERROR: $loggingEnabled = $this->configuration ->get('log_errors') ; break; } if (false === $loggingEnabled) { return; } $this->decoratedLogger ->log( $level, $message, $context ) ; } } ================================================ FILE: src/Infrastructure/Psr/Logger/PsrStreamLogger.php ================================================ outputStreamPath = $outputStreamPath ?? ''; $this->errorStreamPath = $errorStreamPath ?? ''; } public function __destruct() { $this->closeStream($this->outputHandler); $this->closeStream($this->errorHandler); } public function log( $level, string|\Stringable $message, array $context = [], ): void { $resource = match ($level) { LogLevel::INFO => $this->createInfoHandler(), LogLevel::ERROR => $this->createErrorHandler(), default => null, }; if (null === $resource) { return; } /** @var string $level */ $date = $this->formatDate(); $levelFormatted = \mb_strtoupper($level); $extraString = $this->formatContext([]); $contextString = $this->formatContext($context); $formattedMessage = $this->replaceNewlines($message); $record = "[{$date}] crunz.{$levelFormatted}: {$formattedMessage} {$extraString} {$contextString}"; \fwrite($resource, $record . PHP_EOL); } /** @return resource */ private function createInfoHandler() { if (null === $this->outputHandler) { $this->outputHandler = $this->initializeHandler($this->outputStreamPath); } return $this->outputHandler; } /** @return resource */ private function createErrorHandler() { if (null === $this->errorHandler) { $this->errorHandler = $this->initializeHandler($this->errorStreamPath); } return $this->errorHandler; } /** @return resource */ private function initializeHandler(string $path) { if ('' === $path) { throw new CrunzException('Stream path cannot be empty.'); } $directory = $this->dirFromStream($path); if (null !== $directory) { if (\is_file($directory)) { throw new CrunzException( "Unable to create directory '{$directory}', file at this path already exists." ); } if (!\file_exists($directory)) { \mkdir( $directory, 0777, true ); } if (!\is_dir($directory)) { throw new CrunzException("Unable to create directory '{$directory}'."); } } $handler = \fopen($path, 'ab'); if (false === $handler) { throw new CrunzException("Unable to open stream for path: '{$path}'."); } return $handler; } /** @param resource|null $stream */ private function closeStream($stream): void { if (!\is_resource($stream)) { return; } \fclose($stream); } private function dirFromStream(string $stream): ?string { $pos = \mb_strpos($stream, '://'); if (false === $pos) { return \dirname($stream); } if (\str_starts_with($stream, 'file://')) { return \dirname( \mb_substr( $stream, 7 ) ); } return null; } /** @param array $data */ private function formatContext(array $data): string { if ($this->ignoreEmptyContext && empty($data)) { return ''; } return \json_encode($data, JSON_THROW_ON_ERROR); } private function formatDate(): string { $now = $this->clock ->now() ; if ($this->timezoneLog) { $now = $now->setTimezone($this->timezone); } return $now->format(self::DATE_FORMAT); } private function replaceNewlines(string $message): string { if ($this->allowLineBreaks) { if (\str_starts_with($message, '{')) { return \str_replace( ['\r', '\n'], ["\r", "\n"], $message ); } return $message; } return \str_replace( [ "\r\n", "\r", "\n", ], ' ', $message ); } } ================================================ FILE: src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php ================================================ timezoneProvider ->timezoneForComparisons() ; return new EnabledLoggerDecorator( new PsrStreamLogger( $timezone, $this->clock, $configuration->get('output_log_file'), $configuration->get('errors_log_file'), $configuration->get('log_ignore_empty_context'), $configuration->get('timezone_log'), $configuration->get('log_allow_line_breaks') ), $configuration ); } } ================================================ FILE: src/Invoker.php ================================================ $parameters */ public function call($closure, array $parameters = [], $buffer = false): mixed { if ($buffer) { \ob_start(); } $rslt = \call_user_func_array($closure, $parameters); if ($buffer) { return \ob_get_clean(); } return $rslt; } } ================================================ FILE: src/Logger/ConsoleLogger.php ================================================ write($message, self::VERBOSITY_NORMAL); } /** * @param string $message */ public function verbose($message): void { $this->write($message, self::VERBOSITY_VERBOSE); } /** * @param string $message */ public function veryVerbose($message): void { $this->write($message, self::VERBOSITY_VERY_VERBOSE); } /** * Detailed debug information. * * @param string $message */ public function debug($message): void { $this->write($message, self::VERBOSITY_DEBUG); } /** * @param string $message * @param int $verbosity */ private function write($message, $verbosity): void { $ioVerbosity = $this->symfonyStyle ->getVerbosity(); if ($ioVerbosity >= $verbosity) { $this->symfonyStyle ->writeln($message); } } } ================================================ FILE: src/Logger/ConsoleLoggerInterface.php ================================================ log($message, 'info'); } /** * Log the error is error logging is enabled. */ public function error(string $message): void { $this->log($message, 'error'); } private function log(string $content, string $level): void { $this->psrLogger ->log($level, $content) ; } } ================================================ FILE: src/Logger/LoggerFactory.php ================================================ loggerFactory(); $configuration = $this->configuration; $innerLogger = $loggerFactory->create($configuration); return new Logger($innerLogger); } public function createEvent(string $output): Logger { $loggerFactory = $this->loggerFactory(); $eventConfiguration = $this->configuration->withNewEntry('output_log_file', $output); $innerLogger = $loggerFactory->create($eventConfiguration); return new Logger($innerLogger); } private function loggerFactory(): LoggerFactoryInterface { return $this->loggerFactory ??= $this->initializeLoggerFactory(); } private function initializeLoggerFactory(): LoggerFactoryInterface { $timezoneLog = $this->configuration ->get('timezone_log') ; if ($timezoneLog) { $timezone = $this->timezoneProvider ->timezoneForComparisons() ; $this->consoleLogger ->veryVerbose("Timezone for 'timezone_log': '{$timezone->getName()}'") ; } $this->loggerFactory = $this->createLoggerFactory( $this->configuration, $this->timezoneProvider, $this->clock ); return $this->loggerFactory; } private function createLoggerFactory( ConfigurationInterface $configuration, Timezone $timezoneProvider, ClockInterface $clock, ): LoggerFactoryInterface { $params = []; $loggerFactoryClass = $configuration->get('logger_factory'); $this->consoleLogger ->veryVerbose("Class for 'logger_factory': '{$loggerFactoryClass}'.") ; if (!\class_exists($loggerFactoryClass)) { throw new CrunzException("Class '{$loggerFactoryClass}' does not exists."); } $isPsrStreamLoggerFactory = \is_a( $loggerFactoryClass, PsrStreamLoggerFactory::class, true ); if ($isPsrStreamLoggerFactory) { $params[] = $timezoneProvider; $params[] = $clock; } return new $loggerFactoryClass(...$params); } } ================================================ FILE: src/Mailer.php ================================================ getMailer() ->send( $this->getMessage($subject, $message) ) ; } /** * Return the proper mailer. * * @throws MailerException */ private function getMailer(): SymfonyMailer { // If the mailer has already been defined via the constructor, return it. if ($this->mailer) { return $this->mailer; } // Get the proper transporter switch ($this->config('mailer.transport')) { case 'smtp': $transport = $this->getSmtpTransport(); break; case 'mail': throw new MailerException( "'mail' transport is no longer supported, please use 'smtp' or 'sendmail' transport." ); default: $transport = $this->getSendMailTransport(); } $this->mailer = new SymfonyMailer($transport); return $this->mailer; } private function getSmtpTransport(): Transport\TransportInterface { $host = $this->config('smtp.host'); $port = $this->config('smtp.port'); $encryption = \filter_var($this->config('smtp.encryption') ?? true, FILTER_VALIDATE_BOOLEAN); $user = $this->config('smtp.username'); $password = $this->config('smtp.password'); $encryptionString = $encryption ? 1 : 0 ; $userPart = null !== $user && null !== $password ? "{$user}:{$password}@" : '' ; $dsn = "smtp://{$userPart}{$host}:{$port}?verifyPeer={$encryptionString}"; return Transport::fromDsn($dsn); } private function getSendMailTransport(): Transport\TransportInterface { $dsn = 'sendmail://default'; return Transport::fromDsn($dsn); } private function getMessage(string $subject, string $message): Email { $from = new Address($this->config('mailer.sender_email'), $this->config('mailer.sender_name')); $messageObject = new Email(); $messageObject ->from($from) ->subject($subject) ->text($message) ; foreach ($this->config('mailer.recipients') ?? [] as $recipient) { $messageObject->addTo($recipient); } return $messageObject; } private function config(string $key): mixed { return $this->configuration ->get($key) ; } } ================================================ FILE: src/Output/OutputFactory.php ================================================ input; $output = new ConsoleOutput(); if (true === $input->hasParameterOption(['--quiet', '-q'])) { $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); } elseif ($input->hasParameterOption('-vvv') || $input->hasParameterOption('--verbose=3') || 3 === $input->getParameterOption('--verbose')) { $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); } elseif ($input->hasParameterOption('-vv') || $input->hasParameterOption('--verbose=2') || 2 === $input->getParameterOption('--verbose')) { $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); } elseif ($input->hasParameterOption('-v') || $input->hasParameterOption('--verbose=1') || $input->hasParameterOption('--verbose') || $input->getParameterOption('--verbose')) { $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); } return $output; } } ================================================ FILE: src/Path/Path.php ================================================ path; } } ================================================ FILE: src/Pinger/PingableException.php ================================================ checkUrl($url); $this->pingBeforeUrl = $url; return $this; } public function hasPingBefore() { return '' !== $this->pingBeforeUrl; } public function thenPing($url) { $this->checkUrl($url); $this->pingAfterUrl = $url; return $this; } public function hasPingAfter() { return '' !== $this->pingAfterUrl; } public function getPingBeforeUrl() { if (!$this->hasPingBefore()) { throw new PingableException('PingBeforeUrl is empty.'); } return $this->pingBeforeUrl; } public function getPingAfterUrl() { if (!$this->hasPingAfter()) { throw new PingableException('PingAfterUrl is empty.'); } return $this->pingAfterUrl; } /** * @param string $url * * @throws PingableException */ private function checkUrl($url): void { if (!\is_string($url)) { $type = \gettype($url); throw new PingableException("Url must be of type string, '{$type}' given."); } if ('' === $url) { throw new PingableException('Url cannot be empty.'); } } } ================================================ FILE: src/Process/Process.php ================================================ process ->start($callback); } public function wait(): void { $this->process ->wait(); } public function startAndWait(): void { $this->process ->start(); $this->process ->wait(); } /** @param array $env */ public function setEnv(array $env): void { $this->process ->setEnv($env); } public function getPid(): ?int { return $this->process ->getPid(); } public function isRunning(): bool { return $this->process ->isRunning(); } public function isSuccessful(): bool { return $this->process ->isSuccessful(); } public function getOutput(): string { return $this->process ->getOutput(); } public function errorOutput(): string { return $this->process ->getErrorOutput(); } public function commandLine(): string { return $this->process ->getCommandLine() ; } } ================================================ FILE: src/Schedule/ScheduleFactory.php ================================================ singleTask($taskNumber, ...$schedules); $schedule = new Schedule(); $schedule->events([$event]); return [$schedule]; } /** @throws TaskNotExistException */ public function singleTask(TaskNumber $taskNumber, Schedule ...$schedules): Event { $events = \array_map( static fn (Schedule $schedule) => $schedule->events(), $schedules ); $flattenEvents = \array_merge(...$events); if (!isset($flattenEvents[$taskNumber->asArrayIndex()])) { $tasksCount = \count($flattenEvents); throw new TaskNotExistException( "Task with id '{$taskNumber->asInt()}' was not found. Last task id is '{$tasksCount}'." ); } return $flattenEvents[$taskNumber->asArrayIndex()]; } } ================================================ FILE: src/Schedule.php ================================================ compileParameters($parameters); } $this->events[] = $event = new Event($this->id(), $command); return $event; } /** * Register a callback to be called before the operation. * * @return $this */ public function before(\Closure $callback) { $this->beforeCallbacks[] = $callback; return $this; } /** * Register a callback to be called after the operation. * * @return $this */ public function after(\Closure $callback) { return $this->then($callback); } /** * Register a callback to be called after the operation. * * @return $this */ public function then(\Closure $callback) { $this->afterCallbacks[] = $callback; return $this; } /** * Register a callback to call in case of an error. * * @return $this */ public function onError(\Closure $callback) { $this->errorCallbacks[] = $callback; return $this; } /** * Return all registered before callbacks. * * @return \Closure[] */ public function beforeCallbacks() { return $this->beforeCallbacks; } /** * Return all registered after callbacks. * * @return \Closure[] */ public function afterCallbacks() { return $this->afterCallbacks; } /** * Return all registered error callbacks. * * @return \Closure[] */ public function errorCallbacks() { return $this->errorCallbacks; } /** * Get or set the events of the schedule object. * * @param Event[] $events * * @return Event[] */ public function events(?array $events = null) { if (null !== $events) { return $this->events = $events; } return $this->events; } /** * Get all of the events on the schedule that are due. * * @return Event[] */ public function dueEvents(\DateTimeZone $timeZone) { return \array_filter( $this->events, static fn (Event $event) => $event->isDue($timeZone) ); } /** * Dismiss an event after it is finished. * * @param int $key * * @return $this */ public function dismissEvent($key) { unset($this->events[$key]); return $this; } /** * Generate a unique task id. * * @return string */ protected function id() { while (true) { $id = \uniqid('crunz', true); if (!\array_key_exists($id, $this->events)) { return $id; } } } /** @param array $parameters */ protected function compileParameters(array $parameters): string { $isStrings = \array_reduce( $parameters, static fn (bool $carry, $item): bool => $carry && true === \is_string($item), true, ); if (false === $isStrings) { @\trigger_error( 'Passing non-string parameters is deprecated since v3.3, convert all parameters to string.', \E_USER_DEPRECATED ); $parameters = \array_map( static function ($value): string { if (true === \is_bool($value)) { return true === $value ? '1' : '0' ; } return (string) $value; }, $parameters, ); } $flatParameters = []; /** @var string[] $parameters */ foreach ($parameters as $key => $value) { if (false === \is_numeric($key)) { $flatParameters[] = $key; } $flatParameters[] = $value; } return Process::fromArrayCommand($flatParameters)->commandLine(); } } ================================================ FILE: src/Stubs/BasicTask.php ================================================ run('DummyCommand'); $task ->description('DummyDescription') ->in('DummyPath') ->preventOverlapping() ->DummyFrequency() ->DummyConstraint() ; return $scheduler; ================================================ FILE: src/Task/Collection.php ================================================ consoleLogger ->debug("Task source path '{$source}'"); if (!\file_exists($source)) { return []; } $suffix = $this->configuration ->get('suffix') ; $this->consoleLogger ->debug("Task finder suffix: '{$suffix}'"); $realPath = \realpath($source); if (false !== $realPath) { $this->consoleLogger ->verbose("Realpath for '{$source}' is '{$realPath}'"); } else { $this->consoleLogger ->verbose("Realpath resolve for '{$source}' failed."); } $tasks = $this->finder ->find(Path::fromStrings($source), $suffix) ; $tasksCount = \count($tasks); $this->consoleLogger ->debug("Found {$tasksCount} task(s) at path '{$source}'"); return $tasks; } } ================================================ FILE: src/Task/CollectionInterface.php ================================================ loadSchedule($file); if (!$schedule instanceof Schedule) { throw WrongTaskInstanceException::fromFilePath($file, $schedule); } $schedules[] = $schedule; } return $schedules; } /** @return Schedule|mixed */ private function loadSchedule(\SplFileInfo $file) { return require $file->getRealPath(); } } ================================================ FILE: src/Task/LoaderInterface.php ================================================ number = $number; } /** * @param string $value * * @return TaskNumber * * @throws WrongTaskNumberException */ public static function fromString($value) { if (!\is_string($value)) { throw new WrongTaskNumberException('Passed task number is not string.'); } if (!\is_numeric($value)) { throw new WrongTaskNumberException("Task number '{$value}' is not numeric."); } $number = (int) $value; return new self($number); } public function asInt(): int { return $this->number; } public function asArrayIndex(): int { return $this->number - 1; } } ================================================ FILE: src/Task/Timezone.php ================================================ timezoneForComparisons) { return $this->timezoneForComparisons; } $newTimezone = $this->configuration ->get('timezone') ; $this->consoleLogger ->debug("Timezone from config: '{$newTimezone}'."); if (empty($newTimezone)) { throw new EmptyTimezoneException('Timezone must be configured. Please add it to your config file.'); } $this->consoleLogger ->debug("Timezone for comparisons: '{$newTimezone}'."); $this->timezoneForComparisons = new \DateTimeZone($newTimezone); return $this->timezoneForComparisons; } } ================================================ FILE: src/Task/WrongTaskInstanceException.php ================================================ getRealPath(); return new self( "Task at path '{$path}' returned '{$type}', but '{$expectedInstance}' instance is required." ); } } ================================================ FILE: src/Timezone/Provider.php ================================================ setName('closure:run') ->setDescription('Executes a closure as a process.') ->setDefinition( [ new InputArgument( 'closure', InputArgument::REQUIRED, 'The closure to run' ), ] ) ->setHelp('This command executes a closure as a separate process.') ->setHidden(true) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $args = []; /** @var string $closure */ $closure = $input->getArgument('closure'); \parse_str($closure, $args); $serializedClosure = $args[0] ?? ''; if (false === \is_string($serializedClosure)) { $serializedClosure = ''; } $closure = $this->closureSerializer ->unserialize($serializedClosure) ; \call_user_func_array($closure, []); return 0; } } ================================================ FILE: src/UserInterface/Cli/DebugTaskCommand.php ================================================ setDescription('Shows all information about task') ->addArgument( 'taskNumber', InputArgument::REQUIRED, 'Task number from schedule:list command' ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { /** @var string|null $rawTaskNumber */ $rawTaskNumber = $input->getArgument('taskNumber'); $taskNumber = TaskNumber::fromString((string) $rawTaskNumber); $taskInformationView = $this->taskInformationHandler ->handle(new TaskInformation($taskNumber)) ; $table = $this->createTable($taskInformationView, $output, $taskNumber); $table->render(); return 0; } private function createTable( TaskInformationView $taskInformation, OutputInterface $output, TaskNumber $taskNumber, ): Table { $command = $taskInformation->command(); $timeZone = $taskInformation->timeZone(); $configTimeZone = $taskInformation->configTimeZone(); $runDates = \array_map( static fn (\DateTimeImmutable $netRunDate): string => $netRunDate->format('Y-m-d H:i:s e'), $taskInformation->nextRuns() ); $table = new Table($output); $table->setHeaders( [ new TableCell( "Debug information for task '{$taskNumber->asInt()}'", ['colspan' => 2] ), ] ); $table->addRows( [ [ 'Command to run', \is_object($command) ? $command::class : $command, ], [ 'Description', $taskInformation->description(), ], [ 'Prevent overlapping', $taskInformation->preventOverlapping() ? 'Yes' : 'No', ], new TableSeparator(), [ 'Cron expression', $taskInformation->cronExpression(), ], [ 'Comparisons timezone', null !== $timeZone ? "{$timeZone->getName()} (from task)" : "{$configTimeZone->getName()} (from config)", ], new TableSeparator(), [new TableCell('Example run dates', ['colspan' => 2])], ] ); $i = 1; foreach ($runDates as $date) { $table->addRow( [ "#{$i}", $date, ] ); ++$i; } return $table; } } ================================================ FILE: tests/EndToEnd/ClosureRunTest.php ================================================ createEnvironmentBuilder(); $envBuilder ->addTask('ClosureTasks') ->withConfig(['timezone' => 'UTC']) ; $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand('schedule:run'); self::assertStringContainsString( 'Closure output Var: 153', \str_replace( PHP_EOL, ' ', $process->getOutput() ) ); } public function test_prevent_overlapping_works_on_closures(): void { $envBuilder = $this->createEnvironmentBuilder(); $envBuilder ->addTask('NoOverlappingClosureTasks') ->withConfig(['timezone' => 'UTC']) ; $environment = $envBuilder->createEnvironment(); // Warmup Crunz to avoid container's cache race condition $environment->runCrunzCommand('schedule:list'); $firstCall = $environment->runCrunzCommand( 'schedule:run', null, false ); \usleep(50 * 1000); // wait 50ms $secondCall = $environment->runCrunzCommand('schedule:run'); $firstCall->wait(); self::assertStringContainsString('Done', $firstCall->getOutput()); self::assertStringContainsString('No event is due!', $secondCall->getOutput()); } } ================================================ FILE: tests/EndToEnd/ConfigProviderTest.php ================================================ createEnvironmentBuilder(); $environmentBuilder->withConfig(['timezone' => null]); $environment = $environmentBuilder->createEnvironment(); $process = $environment->runCrunzCommand('publish:config'); $configPath = Path::fromStrings($environment->rootDirectory(), ConfigGeneratorCommand::CONFIG_FILE_NAME); $filesystem = new Filesystem(); self::assertTrue($process->isSuccessful(), "Process output: {$process->getOutput()}{$process->errorOutput()}"); self::assertFileExists($configPath->toString()); self::assertIsArray( Yaml::parse( $filesystem->readContent( $configPath->toString() ) ) ); } } ================================================ FILE: tests/EndToEnd/ConfigRecognitionTest.php ================================================ createEnvironmentBuilder(); $environmentBuilder ->changeTaskDirectory($tasksSource) ->addTask('PhpVersionTasks') ->withConfig( [ 'source' => $tasksSource->toString(), 'timezone' => 'UTC', ] ) ; $environment = $environmentBuilder->createEnvironment(); $process = $environment->runCrunzCommand('schedule:list'); $normalizedOutput = $this->normalizeProcessOutput($process); self::assertStringNotContainsString( '[Deprecation] Probably you are relying on legacy config file recognition which is deprecated.', $normalizedOutput ); self::assertStringNotContainsString( '[Deprecation] Probably you are relying on legacy tasks source recognition which is deprecated.', $normalizedOutput ); $this->assertHasTask($normalizedOutput); } private function assertHasTask(string $output): void { self::assertStringContainsString('PHP version', $output); self::assertStringContainsString('php -v', $output); } } ================================================ FILE: tests/EndToEnd/DebugTaskTest.php ================================================ createEnvironmentBuilder(); $envBuilder ->addTask('ClosureTasks') ->withConfig(['timezone' => 'UTC']) ; $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand('task:debug 1'); $output = $process->getOutput(); $contentLines = $this->extractContentLines($output); $expectedValues = [ 'command_to_run' => 'Closure', 'description' => 'Closure with output', 'prevent_overlapping' => 'No', 'cron_expression' => '* * * * *', 'comparisons_timezone' => 'UTC (from config)', ]; $this->assertHeader('debug_information_for_task_1', $contentLines); $this->assertHeader('example_run_dates', $contentLines); foreach ($expectedValues as $expectedKey => $expectedValue) { self::assertArrayHasKey($expectedKey, $contentLines); self::assertSame($expectedValue, $contentLines[$expectedKey]); } for ($i = 1; $i <= 5; ++$i) { $key = "_{$i}"; self::assertArrayHasKey($key, $contentLines); self::assertMatchesRegularExpression( '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:00 UTC$/', $contentLines[$key] ); } } /** @return array */ private function extractContentLines(string $output): array { $outputArray = \explode(PHP_EOL, $output); $contentLines = []; foreach ($outputArray as $line) { $matches = []; $match = \preg_match( "/(?[ a-z0-9#']+) \|? (?[ *\-:()a-z0-9#]+)/im", $line, $matches ); if (1 !== $match) { continue; } $key = \trim($matches['key']); $key = \mb_strtolower($key); $key = \str_replace( [ ' ', '#', "'", ], [ '_', '_', '', ], $key ); $contentLines[$key] = \trim($matches['value']); } return $contentLines; } /** @param array $lines */ private function assertHeader(string $header, array $lines): void { self::assertArrayHasKey($header, $lines); self::assertSame('', $lines[$header]); } } ================================================ FILE: tests/EndToEnd/LoggerTest.php ================================================ createEnvironmentBuilder(); $envBuilder ->addTask('ClosureTasks') ->addTask('FailTasks') ->withConfig( [ 'log_output' => true, 'output_log_file' => 'php://stdout', 'log_errors' => true, 'errors_log_file' => 'php://stderr', 'log_ignore_empty_context' => true, ] ) ; $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand('schedule:run'); $this->assertLogRecord( $process->getOutput(), 'info', 'Closure with output' ); $this->assertLogRecord( $process->errorOutput(), 'error', 'Task that will fail' ); } public function test_event_logging_override(): void { $envBuilder = $this->createEnvironmentBuilder() ->addTask('CustomOutputTasks') ->withConfig( [ 'log_output' => true, 'output_log_file' => 'main.log', ] ) ; $environment = $envBuilder->createEnvironment(); $logPath = $environment->rootDirectory() . DIRECTORY_SEPARATOR; $process = $environment->runCrunzCommand('schedule:run'); self::assertEmpty($process->getOutput()); self::assertFileDoesNotExist("{$logPath}/main.log"); self::assertFileExists("{$logPath}/custom.log"); self::assertStringContainsString( 'Usage: php', (string) \file_get_contents("{$logPath}/custom.log") ); } private function assertLogRecord( string $logRecord, string $level, string $message, ): void { $levelFormatted = \mb_strtoupper($level); self::assertMatchesRegularExpression( "/^\[[0-9]{4}(-[0-9]{2}){2} [0-9]{2}(:[0-9]{2}){2}\] crunz\.{$levelFormatted}:.+?({$message})/", $logRecord ); } } ================================================ FILE: tests/EndToEnd/TasksSourceRecognitionTest.php ================================================ createEnvironmentBuilder(); $envBuilder->addTask('PhpVersionTasks'); $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand('schedule:list'); self::assertStringNotContainsString( '[Deprecation] Probably you are relying on legacy tasks source recognition which', $process->getOutput() ); $this->assertHasTask($process->getOutput()); } /** @test */ public function search_tasks_in_cwd_with_config(): void { $tasksPath = Path::fromStrings('app', 'tasks'); $envBuilder = $this->createEnvironmentBuilder(); $envBuilder ->addTask('PhpVersionTasks') ->changeTaskDirectory($tasksPath) ->withConfig(['source' => $tasksPath->toString()]) ; $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand('schedule:list'); self::assertStringNotContainsString( '[Deprecation] Probably you are relying on legacy tasks source recognition which', $process->getOutput() ); $this->assertHasTask($process->getOutput()); } private function assertHasTask(string $output): void { self::assertStringContainsString('PHP version', $output); self::assertStringContainsString('php -v', $output); } } ================================================ FILE: tests/EndToEnd/VersionTest.php ================================================ createEnvironmentBuilder(); $envBuilder->withConfig(['timezone' => 'UTC']); $environment = $envBuilder->createEnvironment(); $version = InstalledVersions::getPrettyVersion('crunzphp/crunz'); $expectedVersion = "Crunz Command Line Interface {$version}"; // Act $process = $environment->runCrunzCommand('--version'); // Assert self::assertSame( $expectedVersion, \trim( \str_replace( PHP_EOL, ' ', $process->getOutput() ) ) ); } } ================================================ FILE: tests/EndToEnd/WrongTaskTest.php ================================================ createEnvironmentBuilder(); $envBuilder ->addTask('WrongTasks') ->withConfig(['timezone' => 'Europe/Warsaw']) ; $environment = $envBuilder->createEnvironment(); $process = $environment->runCrunzCommand($crunzCommand); $normalizedOutput = $this->normalizeProcessErrorOutput($process); self::assertFalse($process->isSuccessful()); self::assertMatchesRegularExpression( "@Task at path '.*WrongTasks\\.php' returned 'array', but 'C( ?)runz\\\\Schedule' instance is required\.@", $normalizedOutput ); } /** @return iterable */ public static function scheduleInstanceProvider(): iterable { yield 'list' => ['schedule:list']; yield 'run' => ['schedule:run']; } } ================================================ FILE: tests/Functional/ConfigProviderTest.php ================================================ get('publish:config'); $commandTester = new CommandTester($command); $returnCode = $commandTester->execute([]); self::assertSame(0, $returnCode); self::assertStringContainsString('The configuration file already exists at', $commandTester->getDisplay()); } } ================================================ FILE: tests/Functional/DifferentBaseCacheDirTest.php ================================================ get('schedule:list'); $commandTester = new CommandTester($command); $returnCode = $commandTester->execute([]); self::assertSame(0, $returnCode); self::assertStringContainsString('Show PHP version', $commandTester->getDisplay()); } } ================================================ FILE: tests/Functional/ScheduleRunTest.php ================================================ get('schedule:run'); $commandTester = new CommandTester($command); $returnCode = $commandTester->execute([]); self::assertSame(0, $returnCode); self::assertStringContainsString(PHP_VERSION, $commandTester->getDisplay()); } } ================================================ FILE: tests/Functional/TaskGeneratorTest.php ================================================ outputDirectory = \sys_get_temp_dir(); $this->fileName = 'CrunzTest'; $taskFilePath = Path::create( [ $this->outputDirectory, "{$this->fileName}Tasks.php", ] ); $this->taskFilePath = $taskFilePath->toString(); $this->clearTask(); } public function tearDown(): void { $this->clearTask(); } /** @test */ public function generate_task_file(): void { $application = new Application('Crunz', '0.1.0-test.1'); $command = $application->get('make:task'); $commandTester = new CommandTester($command); $this->provideAnswer( "{$this->outputDirectory}\n", $commandTester, $command ); $returnCode = $commandTester->execute( [ 'taskfile' => $this->fileName, ] ); self::assertSame(0, $returnCode); self::assertFileExists($this->taskFilePath); } /** @return resource */ private function getInputStream(string $input) { $stream = \fopen('php://memory', 'rb+', false); if (false === $stream) { throw new \RuntimeException("Unable to open 'php://memory' stream."); } \fwrite($stream, $input); \rewind($stream); return $stream; } private function clearTask(): void { if (\file_exists($this->taskFilePath)) { \unlink($this->taskFilePath); } } private function provideAnswer( string $answer, CommandTester $commandTester, Command $command, ): void { if (\method_exists($commandTester, 'setInputs')) { $commandTester->setInputs([$answer]); return; } $helper = $command->getHelper('question'); $helper->setInputStream($this->getInputStream($answer)); } } ================================================ FILE: tests/TestCase/EndToEnd/Environment/Environment.php ================================================ 'UTC', ]; private string $rootDirectory = ''; /** @var array */ private readonly array $config; private readonly string $tasksDirectory; /** * @param string[] $tasks * @param array $config * * @throws \Exception */ public function __construct( private readonly FilesystemInterface $filesystem, Path $tasksDirectory, array $config = [], private readonly array $tasks = [], ) { $this->config = [...self::DEFAULT_CONFIG, ...$config]; $this->tasksDirectory = $tasksDirectory->toString(); $this->setUp(); } public function __destruct() { $composerLock = Path::fromStrings('composer.lock'); $composerJson = Path::fromStrings('composer.json'); $baseCacheDir = Path::create( [ \sys_get_temp_dir(), '.crunz', ] ); $this->filesystem ->removeDirectory($this->rootDirectory(), [$composerLock, $composerJson]); $this->filesystem ->removeDirectory($baseCacheDir->toString()); } private function setUp(): void { $this->createRootDirectory(); $this->dumpComposerJson(); $this->composerInstall(); $this->copyTasks(); $this->dumpConfig(); } public function runCrunzCommand( string $command, ?string $cwd = null, bool $wait = true, ): Process { $cwd = !empty($cwd) ? $cwd : $this->rootDirectory() ; $isWindows = DIRECTORY_SEPARATOR === '\\'; // On Windows do not add php binary path $phpBinary = $isWindows ? '' : PHP_BINARY ; $crunzBinPath = Path::fromStrings( $this->rootDirectory(), 'vendor', 'bin', 'crunz' ); $commandParts = [ $phpBinary, $crunzBinPath->toString(), $command, // Force no ANSI as this break AppVeyor CI builds '--no-ansi', // Force non-interaction '--no-interaction', ]; $fullCommand = \implode(' ', $commandParts); $process = $this->createProcess($fullCommand, $cwd); $process->setEnv( [ EnvFlags::DEPRECATION_HANDLER_FLAG => '1', EnvFlags::CONTAINER_DEBUG_FLAG => '0', ] ); $process->start(); if ($wait) { $process->wait(); } return $process; } public function rootDirectory(): string { if ('' === $this->rootDirectory) { $tempDir = $this->filesystem ->tempDir(); $rootDirectory = Path::fromStrings($tempDir, 'end2end-test-env'); $this->rootDirectory = $rootDirectory->toString(); } return $this->rootDirectory; } private function dumpConfig(): void { if (empty($this->config)) { return; } $configPath = Path::fromStrings( $this->rootDirectory, ConfigGeneratorCommand::CONFIG_FILE_NAME ); $yamlConfig = Yaml::dump($this->config); $this->filesystem ->dumpFile($configPath->toString(), $yamlConfig); } private function copyTasks(): void { $projectRoot = $this->filesystem ->projectRootDirectory(); $tasksSourceRoot = Path::fromStrings( $projectRoot, 'tests', 'resources', 'tasks' ); $destinationRoot = Path::fromStrings( $this->rootDirectory(), $this->tasksDirectory ); $this->filesystem ->createDirectory($destinationRoot->toString()); foreach ($this->tasks as $task) { $fileName = "{$task}.php"; $sourceTaskPath = Path::fromStrings($tasksSourceRoot->toString(), $fileName); $destinationTaskPath = Path::fromStrings($destinationRoot->toString(), $fileName); $sourceTaskExists = $this->filesystem ->fileExists($sourceTaskPath->toString()); if (!$sourceTaskExists) { throw new \RuntimeException("Task '{$task}' not found at path '{$sourceTaskPath->toString()}'."); } $this->filesystem ->copy($sourceTaskPath->toString(), $destinationTaskPath->toString()); } } private function dumpComposerJson(): void { $composerJson = Path::fromStrings($this->rootDirectory(), 'composer.json'); $projectDir = $this->filesystem ->projectRootDirectory(); $content = [ 'repositories' => [ [ 'type' => 'path', 'url' => $projectDir, 'options' => [ 'symlink' => false, ], ], ], 'require' => [ 'crunzphp/crunz' => '*@dev', ], ]; $contentJson = \json_encode($content, JSON_PRETTY_PRINT); if (false === $contentJson) { throw new \RuntimeException("Unable to encode 'composer.json' content."); } $this->filesystem ->dumpFile($composerJson->toString(), $contentJson) ; } private function composerInstall(): void { $process = $this->createProcess('composer install -q -n', $this->rootDirectory()); $process->startAndWait(); if (!$process->isSuccessful()) { throw new \RuntimeException('Composer install failed'); } } /** @throws \Exception */ private function createRootDirectory(): void { $tempDirectory = $this->filesystem ->tempDir(); if (!\is_writable($tempDirectory)) { throw new \Exception("Unable to setup environment in system's temp dir '{$tempDirectory}'."); } $this->filesystem ->createDirectory($this->rootDirectory()); } private function createProcess(string $command, ?string $cwd = null): Process { return Process::fromStringCommand($command, $cwd); } } ================================================ FILE: tests/TestCase/EndToEnd/Environment/EnvironmentBuilder.php ================================================ */ private array $tasks = []; /** @var array */ private array $config = []; private Path $taskDirectory; public function __construct(private readonly FilesystemInterface $filesystem) { $this->taskDirectory = Path::fromStrings('tasks'); } public function addTask(string $taskName): self { $this->tasks[] = $taskName; return $this; } public function changeTaskDirectory(Path $path): self { $this->taskDirectory = $path; return $this; } /** @param array $config */ public function withConfig(array $config): self { $this->config = $config; return $this; } public function createEnvironment(): Environment { return new Environment( $this->filesystem, $this->taskDirectory, $this->config, $this->tasks ); } } ================================================ FILE: tests/TestCase/EndToEndTestCase.php ================================================ filesystem) { $this->filesystem = new Filesystem(); } return new EnvironmentBuilder($this->filesystem); } protected function normalizeOutput(string $output): string { $noNewLines = \str_replace( ["\n", "\r"], '', $output ); $normalizedOutput = \preg_replace( "/\s+/", ' ', (string) $noNewLines ); return \trim((string) $normalizedOutput); } protected function normalizeProcessOutput(Process $process): string { return $this->normalizeOutput($process->getOutput()); } protected function normalizeProcessErrorOutput(Process $process): string { return $this->normalizeOutput($process->errorOutput()); } } ================================================ FILE: tests/TestCase/FakeConfiguration.php ================================================ 'tasks', 'suffix' => 'Tasks.php', 'timezone' => 'UTC', 'timezone_log' => false, 'log_errors' => false, 'errors_log_file' => null, 'logger_factory' => PsrStreamLoggerFactory::class, 'log_output' => false, 'output_log_file' => null, 'log_allow_line_breaks' => false, 'log_ignore_empty_context' => false, 'email_output' => false, 'email_errors' => false, ]; /** @var array */ private array $config; /** @param array $config */ public function __construct(array $config = []) { $this->config = \array_merge(self::DEFAULT_CONFIG, $config); } public function get(string $key, mixed $default = null): mixed { if (\array_key_exists($key, $this->config)) { return $this->config[$key]; } $parts = \explode('.', $key); $value = $this->config; foreach ($parts as $part) { if (!\is_array($value) || !\array_key_exists($part, $value)) { return $default; } $value = $value[$part]; } return $value; } public function withNewEntry(string $key, mixed $value): ConfigurationInterface { $newConfiguration = clone $this; $parts = \explode('.', $key); if (\count($parts) > 1) { if (\array_key_exists($parts[0], $newConfiguration->config) && \is_array($newConfiguration->config[$parts[0]])) { $newConfiguration->config[$parts[0]][$parts[1]] = $value; } else { $newConfiguration->config[$parts[0]] = [$parts[1] => $value]; } } else { $newConfiguration->config[$key] = $value; } return $newConfiguration; } public function getSourcePath(): string { return (string) $this->get('source', 'tasks'); } } ================================================ FILE: tests/TestCase/FakeLoader.php ================================================ schedules; } } ================================================ FILE: tests/TestCase/FakeTaskCollection.php ================================================ tasks; } } ================================================ FILE: tests/TestCase/Faker.php ================================================ $elements * * @throws CrunzException */ public static function elementFromArray(array $elements): mixed { $itemsCount = \count($elements); if (0 === $itemsCount) { throw new CrunzException('Passed array is empty.'); } $normalizedElements = \array_values($elements); $index = self::int(0, $itemsCount - 1); return $normalizedElements[$index]; } public static function int(int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int { return \random_int($min, $max); } public static function dateTime(string $start = '-20 years', string $end = 'now'): \DateTimeImmutable { $min = new \DateTimeImmutable($start); $max = new \DateTimeImmutable($end); if ($min > $max) { throw new CrunzException("'start' is higher than 'end'."); } $dateTimestamp = self::int($min->getTimestamp(), $max->getTimestamp()); return new \DateTimeImmutable("@{$dateTimestamp}"); } public static function words(int $count = 3): string { $lastWord = \count(self::WORDS_ARRAY) - 1; $words = []; for ($i = 0; $i < $count; ++$i) { $wordIndex = self::int(0, $lastWord); $words[] = self::WORDS_ARRAY[$wordIndex]; } return \implode(' ', $words); } public static function word(): string { return self::words(1); } } ================================================ FILE: tests/TestCase/Logger/NullLogger.php ================================================ */ private array $logs = []; public function log($level, string|\Stringable $message, array $context = []): void { $this->logs[] = [ 'level' => $level, 'message' => $message, 'context' => $context, ]; } /** @return array */ public function getLogs(): array { return $this->logs; } } ================================================ FILE: tests/TestCase/SerializableTaskRunnerStub.php ================================================ } */ public function __serialize(): array { return [ 'taskName' => $this->taskName, 'filters' => $this->filters, ]; } /** @param array{taskName: string, filters: array<\Closure>} $data */ public function __unserialize(array $data): void { $this->taskName = $data['taskName']; $this->filters = $data['filters']; } public function createTask(): \Closure { $this->filters[] = static function (): bool { return true; }; return function (): string { return "running {$this->taskName}"; }; } } ================================================ FILE: tests/TestCase/TaskRunnerStub.php ================================================ filters[] = static function (): bool { return true; }; return function (): string { return "running {$this->taskName}"; }; } } ================================================ FILE: tests/TestCase/TemporaryFile.php ================================================ filePath = $filePath; } public function __destruct() { if (!\file_exists($this->filePath) || !\is_writable(\dirname($this->filePath))) { return; } $streams = \get_resources('stream'); foreach ($streams as $stream) { $uri = \stream_get_meta_data($stream)['uri'] ?? ''; if ($uri === $this->filePath) { \fclose($stream); } } \unlink($this->filePath); } public function filePath(): string { return $this->filePath; } /** @param int $mode */ public function changePermissions($mode): void { $this->checkFileExists(); \chmod($this->filePath, $mode); } public function contents(): string { $this->checkFileExists(); $content = \file_get_contents($this->filePath); if (false === $content) { throw new CrunzException("Unable to read from temporary file '{$this->filePath}'."); } return $content; } private function checkFileExists(): void { if (!\file_exists($this->filePath)) { throw new CrunzException("Temporary file '{$this->filePath}' no longer exists."); } } } ================================================ FILE: tests/TestCase/TestClock.php ================================================ now; } } ================================================ FILE: tests/TestCase/UnitTestCase.php ================================================ closureSerializer ??= new LaravelClosureSerializer(); } protected static function encodeJson(mixed $data): string { return \json_encode($data, JSON_THROW_ON_ERROR); } } ================================================ FILE: tests/Unit/Application/Cron/AbstractCronExpressionTestCase.php ================================================ createExpression($cronExpressionString); $runDates = $cronExpression->multipleRunDates( $total, $now, $timeZone ); self::assertEquals($expectedRunDates, $runDates); } /** * @return iterable< * string, * array{ * string, * \DateTimeImmutable, * int, * null, * \DateTimeImmutable[], * }, * > */ public static function multipleRunDatesProvider(): iterable { $now = new \DateTimeImmutable('2019-01-01 11:12:13'); $nextRuns = [new \DateTimeImmutable('2019-01-01 11:13:00')]; yield 'one every minute' => [ '* * * * *', $now, 1, null, $nextRuns, ]; $now = new \DateTimeImmutable('2019-02-01 05:09:01'); $nextRuns = [ new \DateTimeImmutable('2019-02-01 05:10:00'), new \DateTimeImmutable('2019-02-01 05:15:00'), ]; yield 'two every five minutes' => [ '*/5 * * * *', $now, 2, null, $nextRuns, ]; $timeZone = new \DateTimeZone('Europe/Warsaw'); $now = new \DateTimeImmutable('2019-03-02 07:02:01', $timeZone); $nextRuns = [ new \DateTimeImmutable('2019-03-02 07:10:00', $timeZone), new \DateTimeImmutable('2019-03-02 07:20:00', $timeZone), ]; yield 'two timezone aware' => [ '*/10 * * * *', $now, 2, null, $nextRuns, ]; } abstract protected function createExpression(string $cronExpression): CronExpressionInterface; } ================================================ FILE: tests/Unit/Application/Query/TaskInformation/TaskInformationHandlerTest.php ================================================ createHandler($event, $comparisonsTimeZone); $taskInformation = $taskInformationHandler->handle( new TaskInformation( TaskNumber::fromString('1') ) ); self::assertSame($expectedCommand, $taskInformation->command()); self::assertSame($expectedDescription, $taskInformation->description()); self::assertSame($expectedPreventOverlapping, $taskInformation->preventOverlapping()); self::assertSame($expectedCronExpression, $taskInformation->cronExpression()); self::assertSame($comparisonsTimeZone, $taskInformation->configTimeZone()); self::assertEquals($expectedEventTimeZone, $taskInformation->timeZone()); } /** @return iterable */ public static function taskInformationProvider(): iterable { $id = (string) \random_int(1, 9999); yield 'simple task' => [ new Event($id, 'php -v'), 'php -v', ]; $event = new Event($id, 'php -i'); $event->description('Some description'); yield 'with description' => [ $event, 'php -i', 'Some description', ]; $event = new Event($id, 'php -i'); $event->preventOverlapping(); yield 'with prevent overlapping' => [ $event, 'php -i', '', true, ]; $event = new Event($id, 'php -i'); $event ->everyFiveMinutes() ->weekdays() ; yield 'with cron expression' => [ $event, 'php -i', '', false, '*/5 * * * 1-5', ]; $timeZone = new \DateTimeZone('Europe/Warsaw'); $event = new Event($id, 'php -i'); $event->timezone($timeZone); yield 'with custom comparisons timezone' => [ $event, 'php -i', '', false, '* * * * *', $timeZone, ]; $event = new Event($id, 'php -i'); $event->timezone('Europe/Warsaw'); yield 'with string custom comparisons timezone' => [ $event, 'php -i', '', false, '* * * * *', new \DateTimeZone('Europe/Warsaw'), ]; } private function createHandler(Event $event, \DateTimeZone $comparisonsTimeZone): TaskInformationHandler { $taskCollectionMock = new FakeTaskCollection(); $scheduleFactoryMock = $this->createMock(ScheduleFactory::class); $scheduleFactoryMock ->method('singleTask') ->willReturn($event) ; $timezoneProviderMock = $this->createMock(Timezone::class); $timezoneProviderMock ->method('timezoneForComparisons') ->willReturn($comparisonsTimeZone) ; return new TaskInformationHandler( $timezoneProviderMock, new FakeConfiguration(), $taskCollectionMock, $this->createMock(LoaderInterface::class), $scheduleFactoryMock, new DragonmantankCronExpressionFactory() ); } } ================================================ FILE: tests/Unit/CacheDirectoryFactory/CacheDirectoryFactoryTest.php ================================================ generate(); $expectedResult = Path::fromStrings(\sys_get_temp_dir(), '.crunz'); self::assertEquals($expectedResult, $result); } /** @test */ public function change_cache_directory_through_environment_variable(): void { $newDirectoryPath = '/new/directory/path'; \putenv(CacheDirectoryFactory::CRUNZ_BASE_CACHE_DIR . "={$newDirectoryPath}"); $cacheDirectoryFactory = new CacheDirectoryFactory(); $result = $cacheDirectoryFactory->generate(); $expectedResult = Path::fromStrings($newDirectoryPath, '.crunz'); self::assertEquals($expectedResult, $result); } /** @test */ public function throw_exception_when_environment_variable_is_empty(): void { $newDirectoryPath = ' '; \putenv(CacheDirectoryFactory::CRUNZ_BASE_CACHE_DIR . "={$newDirectoryPath}"); $cacheDirectoryFactory = new CacheDirectoryFactory(); self::expectException(CrunzException::class); $cacheDirectoryFactory->generate(); } } ================================================ FILE: tests/Unit/Configuration/ConfigurationParserTest.php ================================================ addToAssertionCount(1); $fileParserMock = $this->createMock(FileParser::class); $fileParserMock ->method('parse') ->willThrowException(ConfigFileNotExistsException::fromFilePath('/path')) ; $configurationParser = $this->createConfigurationParser($fileParserMock, []); $configurationParser->parseConfig(); } /** @test */ public function use_parsed_config_when_config_file_exists(): void { $this->addToAssertionCount(1); $parsedConfig = ['some' => 'config']; $fileParserMock = $this->createMock(FileParser::class); $fileParserMock ->method('parse') ->willReturn($parsedConfig) ; $configurationParser = $this->createConfigurationParser($fileParserMock, $parsedConfig); $configurationParser->parseConfig(); } /** @param array $expectedProcessedConfig */ private function createConfigurationParser( FileParser $fileParser, array $expectedProcessedConfig, ): ConfigurationParser { $definition = new Definition(); $definitionProcessorMock = $this->createMock(Processor::class); $definitionProcessorMock ->method('processConfiguration') ->with($definition, $expectedProcessedConfig) ->willReturn([]) ; $filesystemMock = $this->createMock(FilesystemInterface::class); $filesystemMock ->method('fileExists') ->willReturn(true) ; return new ConfigurationParser( $definition, $definitionProcessorMock, $fileParser, new NullLogger(), $filesystemMock ); } } ================================================ FILE: tests/Unit/Configuration/ConfigurationTest.php ================================================ createConfiguration( [ 'smtp' => [ 'port' => 1234, ], ] ); self::assertSame(1234, $configuration->get('smtp.port')); } /** @test */ public function get_return_default_value_if_path_not_exists(): void { $configuration = $this->createConfiguration(); self::assertNull($configuration->get('wrong')); self::assertSame('anon', $configuration->get('notExist', 'anon')); } /** @test */ public function source_path_is_relative_to_cwd(): void { $cwd = \sys_get_temp_dir(); $sourcePath = Path::fromStrings('app', 'tasks'); $expectedPath = Path::fromStrings($cwd, $sourcePath->toString()); $configuration = $this->createConfiguration(['source' => $sourcePath->toString()], $cwd); self::assertSame($expectedPath->toString(), $configuration->getSourcePath()); } /** @test */ public function source_path_fallback_to_tasks_directory(): void { $cwd = \sys_get_temp_dir(); $expectedPath = Path::fromStrings($cwd, 'tasks'); $configuration = $this->createConfiguration([], $cwd); self::assertSame($expectedPath->toString(), $configuration->getSourcePath()); } /** @test */ public function set_configuration_key_value(): void { $cwd = \sys_get_temp_dir(); $sourcePath = Path::fromStrings('app', 'tasks'); $configuration = $this->createConfiguration(['source' => $sourcePath->toString()], $cwd); $keyName = 'test_key'; $expectedValue = 'test_value'; $newConfiguration = $configuration->withNewEntry($keyName, $expectedValue); self::assertSame($newConfiguration->get($keyName), $expectedValue); } /** @test */ public function set_configuration_key_array(): void { $cwd = \sys_get_temp_dir(); $sourcePath = Path::fromStrings('app', 'tasks'); $configuration = $this->createConfiguration(['source' => $sourcePath->toString()], $cwd); $arrayName = 'test_array'; $keyName = 'test_key'; $expectedValue = 'test_value'; $newConfiguration = $configuration->withNewEntry("{$arrayName}.{$keyName}", $expectedValue); $expectedArray = $newConfiguration->get($arrayName); self::assertIsArray($expectedArray); self::assertArrayHasKey($keyName, $expectedArray); self::assertSame($expectedArray[$keyName], $expectedValue); } /** @param array $config */ private function createConfiguration(array $config = [], string $cwd = ''): Configuration { $mockConfigurationParser = $this->createMock(ConfigurationParserInterface::class); $mockConfigurationParser ->method('parseConfig') ->willReturn($config) ; $mockFilesystem = $this->createMock(FilesystemInterface::class); $mockFilesystem ->method('getCwd') ->willReturn($cwd) ; return new Configuration($mockConfigurationParser, $mockFilesystem); } } ================================================ FILE: tests/Unit/Configuration/FileParserTest.php ================================================ expectException(ConfigFileNotExistsException::class); $this->expectExceptionMessage("Configuration file '{$filePath}' not exists."); $parser = $this->createFileParser(); $parser->parse($filePath); } /** @test */ public function parse_throws_exception_on_non_readable_file(): void { if ($this->isWindows()) { self::markTestSkipped('Required Unix-based OS.'); } $tempFile = new TemporaryFile(); $tempFile->changePermissions(0200); $filePath = $tempFile->filePath(); $this->expectException(ConfigFileNotReadableException::class); $this->expectExceptionMessage("Config file '{$filePath}' is not readable."); $parser = $this->createFileParser(); $parser->parse($filePath); } /** @test */ public function parse_returns_parsed_file_content(): void { $tempFile = new TemporaryFile(); $filePath = $tempFile->filePath(); $configData = [ 'suffix' => 'Task.php', 'source' => 'tasks', ]; \file_put_contents($filePath, Yaml::dump($configData)); $parser = $this->createFileParser(); self::assertSame([$configData], $parser->parse($filePath)); } /** * @return FileParser */ private function createFileParser() { return new FileParser(new Yaml()); } /** * @return bool */ private function isWindows() { return DIRECTORY_SEPARATOR === '\\'; } } ================================================ FILE: tests/Unit/Console/Command/ScheduleListCommandTest.php ================================================ createCommand(); // Expect $this->expectException(CrunzException::class); $this->expectExceptionMessage("Format '{$format}' is not supported."); // Act $command->run( $this->createInput($format), new NullOutput(), ); } /** @dataProvider formatProvider */ public function test_list_output_format(\Closure $paramsGenerator): void { // Arrange /** * @var string $format * @var string $expectedOutput * @var \Closure $assert */ [ 'format' => $format, 'expectedOutput' => $expectedOutput, 'assert' => $assert, ] = $paramsGenerator(); $output = new BufferedOutput(); $commandString = 'php -v'; $cronExpression = '15 3 * * 1,3,5'; $description = 'PHP version'; $schedule = self::createScheduleWithTask( $commandString, $description, $cronExpression, ); $command = $this->createCommand([$schedule]); // Act $command->run( $this->createInput($format), $output, ); // Assert $assert($expectedOutput, $output->fetch()); } /** @return iterable */ public static function formatProvider(): iterable { yield 'text' => [ function (): array { $commandString = 'php -v'; $cronExpression = '15 3 * * 1,3,5'; $description = 'PHP version'; $schedule = self::createScheduleWithTask( $commandString, $description, $cronExpression, ); return [ 'format' => 'text', 'schedule' => $schedule, 'expectedOutput' => << static function (string $expectedOutput, string $actualOutput): void { self::assertSame($expectedOutput, $actualOutput); }, ]; }, ]; yield 'json' => [ function (): array { $commandString = 'php -v'; $cronExpression = '15 3 * * 1,3,5'; $description = 'PHP version'; $schedule = self::createScheduleWithTask( $commandString, $description, $cronExpression, ); return [ 'format' => 'json', 'schedule' => $schedule, 'expectedOutput' => self::encodeJson( [ [ 'command' => $commandString, 'expression' => $cronExpression, 'number' => 1, 'task' => $description, ], ], ), 'assert' => static function (string $expectedOutput, string $actualOutput): void { self::assertJsonStringEqualsJsonString($expectedOutput, $actualOutput); }, ]; }, ]; } /** @param Schedule[] $schedules */ private function createCommand(array $schedules = []): ScheduleListCommand { return new ScheduleListCommand( new FakeConfiguration(), new FakeTaskCollection(), new FakeLoader($schedules), ); } private function createInput(string $format): InputInterface { return new ArrayInput( [ '--format' => $format, ] ); } private static function createScheduleWithTask( string $command, string $description, string $cronExpression, ): Schedule { $schedule = new Schedule(); $schedule ->run($command) ->description($description) ->cron($cronExpression) ; return $schedule; } } ================================================ FILE: tests/Unit/Console/Command/ScheduleRunCommandTest.php ================================================ createTaskFile($this->taskContent(), $tempFile); $mockInput = $this->mockInput( [ 'force' => true, 'task' => null, ], ['source' => ''] ); $mockOutput = $this->createMock(OutputInterface::class); $mockTaskCollection = $this->mockTaskCollection($filename); $mockEventRunner = $this->mockEventRunner($mockOutput); $command = new ScheduleRunCommand( $mockTaskCollection, new FakeConfiguration(['source' => '']), $mockEventRunner, $this->createMock(Timezone::class), $this->createMock(ScheduleFactory::class), $this->createTaskLoader() ); $command->run( $mockInput, $mockOutput ); } /** @test */ public function run_specific_task(): void { $tempFile1 = new TemporaryFile(); $tempFile2 = new TemporaryFile(); $filename1 = $this->createTaskFile($this->phpVersionTaskContent(), $tempFile1); $filename2 = $this->createTaskFile($this->phpVersionTaskContent(), $tempFile2); $mockInput = $this->mockInput( [ 'force' => false, 'task' => '1', ], ['source' => ''] ); $mockOutput = $this->createMock(OutputInterface::class); $mockTaskCollection = $this->mockTaskCollection($filename1, $filename2); $mockEventRunner = $this->mockEventRunner($mockOutput); $command = new ScheduleRunCommand( $mockTaskCollection, new FakeConfiguration(['source' => '']), $mockEventRunner, self::mockTimezoneProvider(), $this->mockScheduleFactory(), $this->createTaskLoader() ); $command->run( $mockInput, $mockOutput ); } public static function mockTimezoneProvider(): MockObject&Timezone { $timeZone = new \DateTimeZone('UTC'); /** @var MockObject&Timezone $timezoneProviderMock */ $timezoneProviderMock = (new MockGenerator())->testDouble( Timezone::class, true, [], [], '', false, false, true, false, false, null, false, ); $timezoneProviderMock->method('timezoneForComparisons')->willReturn($timeZone); return $timezoneProviderMock; } private function mockScheduleFactory(): ScheduleFactory { $mockEvent = $this->createMock(Event::class); $mockSchedule = $this->createConfiguredMock(Schedule::class, ['events' => [$mockEvent]]); $mockScheduleFactory = $this->createMock(ScheduleFactory::class); $mockScheduleFactory ->expects(self::once()) ->method('singleTaskSchedule') ->willReturn([$mockSchedule]) ; return $mockScheduleFactory; } /** @return EventRunner|MockObject */ private function mockEventRunner(OutputInterface $output): EventRunner { $mockEventRunner = $this->createMock(EventRunner::class); $mockEventRunner ->expects(self::once()) ->method('handle') ->with( $output, self::callback( function ($schedules) { $isArray = \is_array($schedules); $count = \is_countable($schedules) ? \count($schedules) : 0; $schedule = \reset($schedules); return $isArray && 1 === $count && $schedule instanceof Schedule ; } ) ) ; return $mockEventRunner; } /** * @param array $options * @param array $arguments * * @return MockObject|InputInterface */ private function mockInput(array $options, array $arguments = []): InputInterface { $mockInput = $this->createMock(InputInterface::class); $mockInput ->method('getOptions') ->willReturn($options) ; $mockInput ->method('getArguments') ->willReturn($arguments) ; return $mockInput; } private function mockTaskCollection(string ...$taskFiles): CollectionInterface { $mocksFileInfo = \array_map( fn ($taskFile) => $this->createConfiguredMock(\SplFileInfo::class, ['getRealPath' => $taskFile]), $taskFiles ); return new FakeTaskCollection($mocksFileInfo); } private function createTaskFile(string $taskContent, TemporaryFile $file): string { $filesystem = new Filesystem(); $filename = $file->filePath(); $filesystem->touch($filename); $filesystem->dumpFile($filename, $taskContent); return $filename; } private function taskContent(): string { return <<<'PHP_WRAP' run('php -v') ->description('Show PHP version') // Always skip ->skip(static function () {return true;}) ; return $schedule; PHP_WRAP; } private function phpVersionTaskContent(): string { return <<<'PHP_WRAP' run('php -v') ->everyMinute() ->description('Show PHP version') ; return $schedule; PHP_WRAP; } private function createTaskLoader(): LoaderInterface { return new Loader(); } } ================================================ FILE: tests/Unit/EnvFlags/EnvFlagsTest.php ================================================ isDeprecationHandlerEnabled()); } /** @test */ public function deprecation_handler_can_be_disabled(): void { $envFlags = new EnvFlags(); $envFlags->disableDeprecationHandler(); $this->assertFlagValue(EnvFlags::DEPRECATION_HANDLER_FLAG, '0'); } /** @test */ public function deprecation_handler_can_be_enabled(): void { $envFlags = new EnvFlags(); $envFlags->enableDeprecationHandler(); $this->assertFlagValue(EnvFlags::DEPRECATION_HANDLER_FLAG, '1'); } /** * @test * * @dataProvider containerDebugProvider */ public function container_debug_flag_is_correct(string $flagValue, bool $expectedEnabled): void { \putenv(EnvFlags::CONTAINER_DEBUG_FLAG . "={$flagValue}"); $envFlags = new EnvFlags(); self::assertSame($expectedEnabled, $envFlags->isContainerDebugEnabled()); } /** @test */ public function container_debug_can_be_disabled(): void { $envFlags = new EnvFlags(); $envFlags->disableContainerDebug(); $this->assertFlagValue(EnvFlags::CONTAINER_DEBUG_FLAG, '0'); } /** @test */ public function container_debug_can_be_enabled(): void { $envFlags = new EnvFlags(); $envFlags->enableContainerDebug(); $this->assertFlagValue(EnvFlags::CONTAINER_DEBUG_FLAG, '1'); } /** @return iterable */ public static function statusProvider(): iterable { yield 'true' => [ '1', true, ]; yield 'false' => [ '0', false, ]; } /** @return iterable */ public static function containerDebugProvider(): iterable { yield 'true' => [ '1', true, ]; yield 'false' => [ '0', false, ]; } private function assertFlagValue(string $flag, string $expectedValue): void { $actualValue = \getenv($flag); self::assertSame($expectedValue, $actualValue); } } ================================================ FILE: tests/Unit/EventRunnerTest.php ================================================ createMock(OutputInterface::class); $eventRunner = $this->createEventRunnerForPing($url); $schedule = new Schedule(); $event = $schedule->run('php -v'); $event->pingBefore($url); $eventRunner->handle($output, [$schedule]); } /** @test */ public function url_is_pinged_after(): void { $url = 'https://ping-aft.er/'; $output = $this->createMock(OutputInterface::class); $eventRunner = $this->createEventRunnerForPing($url); $schedule = new Schedule(); $event = $schedule->run('php -v'); $event->thenPing($url); $eventRunner->handle($output, [$schedule]); } public function test_event_logging_configuration(): void { $logTarget = 'event.log'; // create schedule with event that changes logging configuration $schedule = new Schedule(); $schedule->run('php -v') ->appendOutputTo($logTarget) ; // mock the LoggerFactory $loggerFactory = $this->createMock(LoggerFactory::class); $loggerFactory->expects(self::once()) ->method('createEvent') ->with($logTarget); // create an EventRunner to handle the Schedule $eventRunner = new EventRunner( $this->createMock(Invoker::class), new FakeConfiguration(), $this->createMock(Mailer::class), $loggerFactory, $this->createMock(HttpClientInterface::class), $this->createMock(ConsoleLoggerInterface::class) ); $output = $this->createMock(OutputInterface::class); $eventRunner->handle($output, [$schedule]); } public function test_lock_is_released_on_error(): void { $output = $this->createMock(OutputInterface::class); if (\interface_exists(StoreInterface::class)) { $mockStore = $this->createMock(StoreInterface::class); } else { $mockStore = $this->createMock(BlockingStoreInterface::class); } $mockStore ->expects(self::once()) ->method('delete') ; $schedule = new Schedule(); $event = $schedule->run('wrong-command'); $event->preventOverlapping($mockStore); $eventRunner = $this->createEventRunner(true); $eventRunner->handle($output, [$schedule]); } /** * @param string $url * * @return EventRunner */ private function createEventRunnerForPing($url) { $invoker = $this->createMock(Invoker::class); $mailer = $this->createMock(Mailer::class); $loggerFactory = $this->createMock(LoggerFactory::class); $httpClient = $this->createMock(HttpClientInterface::class); $consoleLogger = $this->createMock(ConsoleLoggerInterface::class); $httpClient ->expects(self::once()) ->method('ping') ->with($url) ; return new EventRunner( $invoker, new FakeConfiguration(), $mailer, $loggerFactory, $httpClient, $consoleLogger ); } private function createEventRunner(bool $realInvoker = false): EventRunner { $invoker = true === $realInvoker ? new Invoker() : $this->createMock(Invoker::class) ; $mailer = $this->createMock(Mailer::class); $loggerFactory = $this->createMock(LoggerFactory::class); $httpClient = $this->createMock(HttpClientInterface::class); $consoleLogger = $this->createMock(ConsoleLoggerInterface::class); return new EventRunner( $invoker, new FakeConfiguration(), $mailer, $loggerFactory, $httpClient, $consoleLogger ); } } ================================================ FILE: tests/Unit/EventTest.php ================================================ id = \uniqid('crunz', true); $this->defaultTimezone = \date_default_timezone_get(); \date_default_timezone_set('UTC'); } public function tearDown(): void { \date_default_timezone_set($this->defaultTimezone); } /** * @group cronCompile */ public function test_unit_methods(): void { $e = new Event($this->id, 'php foo'); self::assertEquals('0 * * * *', $e->hourly()->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('0 0 * * *', $e->daily()->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('45 15 * * *', $e->dailyAt('15:45')->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('0 4,8 * * *', $e->twiceDaily(4, 8)->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('0 0 * * 0', $e->weekly()->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('0 0 1 * *', $e->monthly()->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('0 0 1 */3 *', $e->quarterly()->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('0 0 1 1 *', $e->yearly()->getExpression()); } /** * @group cronCompile */ public function test_low_level_methods(): void { $timezone = new \DateTimeZone('UTC'); $e = new Event($this->id, 'php foo'); self::assertEquals('30 1 11 4 *', $e->on('01:30 11-04-2016')->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('45 13 * * *', $e->on('13:45')->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('45 13 * * *', $e->at('13:45')->getExpression()); $e = new Event($this->id, 'php bar'); $e->minute([12, 24, 35]) ->hour('1-5', 4, 8) ->dayOfMonth(1, 6, 12, 19, 25) ->month('1-8') ->dayOfWeek('mon,wed,thu'); self::assertEquals('12,24,35 1-5,4,8 1,6,12,19,25 1-8 mon,wed,thu', $e->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('45 13 * * *', $e->cron('45 13 * * *')->getExpression()); $e = new Event($this->id, 'php foo'); self::assertTrue($e->isDue($timezone)); } /** * @group cronCompile */ public function test_weekday_methods(): void { $e = new Event($this->id, 'php qux'); self::assertEquals('* * * * 2', $e->tuesdays()->getExpression()); $e = new Event($this->id, 'php flob'); self::assertEquals('* * * * 3', $e->wednesdays()->getExpression()); $e = new Event($this->id, 'php foo'); self::assertEquals('* * * * 4', $e->thursdays()->getExpression()); $e = new Event($this->id, 'php bar'); self::assertEquals('* * * * 5', $e->fridays()->getExpression()); $e = new Event($this->id, 'php baz'); self::assertEquals('* * * * 1-5', $e->weekdays()->getExpression()); $e = new Event($this->id, 'php bla'); self::assertEquals('30 1 * * 2', $e->weeklyOn('2', '01:30')->getExpression()); } public function test_cron_life_time(): void { $timezone = new \DateTimeZone('UTC'); $event = new Event($this->id, 'php foo'); self::assertFalse( $event ->between('2015-01-01', '2015-01-02') ->isDue($timezone) ); $futureDate = new \DateTimeImmutable('+1 year'); $event = new Event($this->id, 'php foo'); self::assertFalse( $event ->from($futureDate->format('Y-m-d')) ->isDue($timezone) ); $event = new Event($this->id, 'php foo'); self::assertFalse( $event ->to('2015-01-01') ->isDue($timezone) ); } /** * @param \Closure(): array{ * dateFrom: string, * dateTo: string * } $paramsGenerator * * @dataProvider dateFromToProvider */ public function test_get_from(\Closure $paramsGenerator): void { $params = $paramsGenerator(); $event = new Event($this->id, 'php foo'); $event->from($params['dateFrom']); self::assertSame($params['dateFrom'], $event->getFrom()); } /** * @param \Closure(): array{ * dateFrom: string, * dateTo: string * } $paramsGenerator * * @dataProvider dateFromToProvider */ public function test_get_to(\Closure $paramsGenerator): void { $params = $paramsGenerator(); $event = new Event($this->id, 'php foo'); $event->to($params['dateTo']); self::assertSame($params['dateTo'], $event->getTo()); } /** * @param \Closure(): array{ * dateFrom: string, * dateTo: string * } $paramsGenerator * * @dataProvider dateFromToProvider */ public function test_get_between(\Closure $paramsGenerator): void { $params = $paramsGenerator(); $event = new Event($this->id, 'php foo'); $event->between($params['dateFrom'], $params['dateTo']); self::assertSame($params['dateFrom'], $event->getFrom()); self::assertSame($params['dateTo'], $event->getTo()); } public function test_cron_conditions(): void { $timezone = new \DateTimeZone('UTC'); $e = new Event($this->id, 'php foo'); self::assertFalse($e->cron('* * * * *')->when(fn () => false)->isDue($timezone)); $e = new Event($this->id, 'php foo'); self::assertTrue($e->cron('* * * * *')->when(fn () => true)->isDue($timezone)); $e = new Event($this->id, 'php foo'); self::assertFalse($e->cron('* * * * *')->skip(fn () => true)->isDue($timezone)); $e = new Event($this->id, 'php foo'); self::assertTrue($e->cron('* * * * *')->skip(fn () => false)->isDue($timezone)); } /** @test */ public function more_than_five_parts_in_cron_expression_results_in_exception(): void { $this->expectException(TaskException::class); $this->expectExceptionMessage("Expression '* * * * * *' has more than five parts and this is not allowed."); $e = new Event(1, 'php foo -v'); $e->cron('* * * * * *'); } public function test_build_command(): void { $e = new Event($this->id, 'php -i'); self::assertSame('php -i', $e->buildCommand()); } public function test_is_due(): void { $timezone = new \DateTimeZone('UTC'); $this->setClockNow(new \DateTimeImmutable('2015-04-12 00:00:00', $timezone)); $e = new Event($this->id, 'php foo'); self::assertTrue($e->sundays()->isDue($timezone)); $e = new Event($this->id, 'php bar'); self::assertEquals('0 19 * * 6', $e->saturdays()->at('19:00')->timezone('EST')->getExpression()); self::assertTrue($e->isDue($timezone)); $e = new Event($this->id, 'php bar'); $this->setClockNow(new \DateTimeImmutable(\date('Y') . '-04-12 00:00:00')); self::assertTrue($e->on('00:00 ' . \date('Y') . '-04-12')->isDue($timezone)); } public function test_name(): void { $e = new Event($this->id, 'php foo'); $e->description('Testing Cron'); self::assertEquals('Testing Cron', $e->description); } /** @test */ public function in_change_working_directory_in_build_command_on_windows(): void { if (!$this->isWindows()) { self::markTestSkipped('Required Windows OS.'); } $workingDir = 'C:\\windows\\temp'; $event = new Event($this->id, 'php -v'); $event->in($workingDir); self::assertSame("cd /d {$workingDir} & php -v", $event->buildCommand()); } /** @test */ public function in_change_working_directory_in_build_command_on_unix(): void { if ($this->isWindows()) { self::markTestSkipped('Required Unix-based OS.'); } $event = new Event($this->id, 'php -v'); $event->in('/tmp'); self::assertSame('cd /tmp; php -v', $event->buildCommand()); } /** @test */ public function on_do_not_run_task_every_minute(): void { $event = new Event($this->id, 'php -i'); $event->on('Thursday 8:00'); self::assertSame('0 8 * * *', $event->getExpression()); } /** @test */ public function setting_user_prepend_sudo_to_command(): void { if ($this->isWindows()) { self::markTestSkipped('Required Unix-based OS.'); } $event = new Event($this->id, 'php -v'); $event->user('john'); self::assertSame('sudo -u john php -v', $event->buildCommand()); } /** @test */ public function custom_user_and_cwd(): void { if ($this->isWindows()) { self::markTestSkipped('Required Unix-based OS.'); } $event = new Event($this->id, 'php -i'); $event->user('john'); $event->in('/var/test'); self::assertSame('sudo -u john cd /var/test; sudo -u john php -i', $event->buildCommand()); } /** @test */ public function not_implemented_user_change_on_windows(): void { if (!$this->isWindows()) { self::markTestSkipped('Required Windows OS.'); } $this->expectException(\Crunz\Exception\NotImplementedException::class); $this->expectExceptionMessage('Changing user on Windows is not implemented.'); $event = new Event($this->id, 'php -i'); $event->user('john'); } /** * @test * * @runInSeparateProcess */ public function closure_command_have_full_binary_paths(): void { if (!\defined('CRUNZ_BIN')) { \define('CRUNZ_BIN', __FILE__); } $closure = fn () => 0; $closureSerializer = $this->createClosureSerializer(); $serializedClosure = $closureSerializer->serialize($closure); $queryClosure = \http_build_query([$serializedClosure]); $crunzBin = CRUNZ_BIN; $event = new Event($this->id, $closure); $command = $event->buildCommand(); self::assertSame(\escapeshellarg(PHP_BINARY) . ' ' . \escapeshellarg($crunzBin) . " closure:run {$queryClosure}", $command); } /** @test */ public function whole_output_catches_stdout_and_stderr(): void { $command = "php -r \"echo 'Test output'; throw new \Exception('Exception output');\""; $event = new Event(\uniqid('c', true), $command); $event->start(); $process = $event->getProcess(); while ($process->isRunning()) { \usleep(20000); // wait 20 ms } $wholeOutput = $event->wholeOutput(); self::assertStringContainsString( 'Test output', $wholeOutput, 'Missing standard output' ); self::assertStringContainsString( 'Exception output', $wholeOutput, 'Missing error output' ); } /** @test */ public function task_will_prevent_overlapping_with_default_store(): void { $this->assertPreventOverlapping(); } /** @test */ public function task_will_prevent_overlapping_with_semaphore_store(): void { if (!\extension_loaded('sysvsem')) { self::markTestSkipped('Semaphore extension not installed.'); } $this->assertPreventOverlapping(new SemaphoreStore()); } /** @dataProvider everyMethodProvider */ public function test_every_methods(string $method, string $expectedCronExpression): void { // Arrange $event = new Event($this->id, 'php -i'); /** @var callable $methodCall */ $methodCall = [$event, $method]; $methodCallClosure = \Closure::fromCallable($methodCall); // Act $methodCallClosure(); // Assert self::assertSame($expectedCronExpression, $event->getExpression()); } public function test_hourly_at_with_valid_minute(): void { // Arrange $event = $this->createEvent(); $minute = Faker::int(0, 59); // Act $event->hourlyAt($minute); // Assert self::assertSame("{$minute} * * * *", $event->getExpression()); } /** @dataProvider hourlyAtInvalidProvider */ public function test_hourly_at_with_invalid_minute( int $minute, string $expectedExceptionMessage, ): void { // Arrange $event = $this->createEvent(); // Expect $this->expectException(CrunzException::class); $this->expectExceptionMessage($expectedExceptionMessage); // Act $event->hourlyAt($minute); } public function test_non_blocking_store_can_be_passed_to_prevent_overlapping(): void { // Arrange $store = new PdoStore(''); $event = $this->createEvent(); // Expect $this->expectNotToPerformAssertions(); // Act $event->preventOverlapping($store); } /** * @param \Closure(): array{ * now: \DateTimeImmutable, * fromDateTime: string, * timeZone: \DateTimeZone, * expectedIsDue: bool, * } $paramsGenerator * * @dataProvider fromTimeZoneProvider */ public function test_from_respects_time_zone(\Closure $paramsGenerator): void { // Arrange [ 'now' => $now, 'fromDateTime' => $fromDateTime, 'timeZone' => $timeZone, 'expectedIsDue' => $expectedIsDue, ] = $paramsGenerator(); $this->setClockNow($now); $event = $this->createEvent(); $event->from($fromDateTime); // Act $isDue = $event->isDue($timeZone); // Assert self::assertSame($expectedIsDue, $isDue); } /** * @param \Closure(): array{ * now: \DateTimeImmutable, * toDateTime: string, * timeZone: \DateTimeZone, * expectedIsDue: bool, * } $paramsGenerator * * @dataProvider toTimeZoneProvider */ public function test_to_respects_timezone(\Closure $paramsGenerator): void { // Arrange [ 'now' => $now, 'toDateTime' => $toDateTime, 'timeZone' => $timeZone, 'expectedIsDue' => $expectedIsDue, ] = $paramsGenerator(); $this->setClockNow($now); $event = $this->createEvent(); $event->to($toDateTime); // Act $isDue = $event->isDue($timeZone); // Assert self::assertSame($expectedIsDue, $isDue); } /** @return iterable */ public static function deprecatedEveryProvider(): iterable { yield 'every seven minutes' => ['everySevenMinutes']; yield 'every five hours' => ['everyFiveHours']; yield 'every two days' => ['everyTwoDays']; yield 'every five months' => ['everyFiveMonths']; } /** @return iterable */ public static function everyMethodProvider(): iterable { yield 'every minute' => ['everyMinute', '* * * * *']; yield 'every two minutes' => ['everyTwoMinutes', '*/2 * * * *']; yield 'every three minutes' => ['everyThreeMinutes', '*/3 * * * *']; yield 'every four minutes' => ['everyFourMinutes', '*/4 * * * *']; yield 'every five minutes' => ['everyFiveMinutes', '*/5 * * * *']; yield 'every ten minutes' => ['everyTenMinutes', '*/10 * * * *']; yield 'every fifteen minutes' => ['everyFifteenMinutes', '*/15 * * * *']; yield 'every thirty minutes' => ['everyThirtyMinutes', '*/30 * * * *']; yield 'every two hours' => ['everyTwoHours', '0 */2 * * *']; yield 'every three hours' => ['everyThreeHours', '0 */3 * * *']; yield 'every four hours' => ['everyFourHours', '0 */4 * * *']; yield 'every six hours' => ['everySixHours', '0 */6 * * *']; } /** @return iterable */ public static function fromTimeZoneProvider(): iterable { yield 'same timezone' => [ static function (): array { $timeZone = new \DateTimeZone('Europe/Warsaw'); return [ 'now' => new \DateTimeImmutable( '12:01', $timeZone, ), 'fromDateTime' => '12:00', 'timeZone' => $timeZone, 'expectedIsDue' => true, ]; }, ]; yield 'different timezones' => [ static fn (): array => [ 'now' => new \DateTimeImmutable( '12:00', new \DateTimeZone('Europe/Warsaw'), ), 'fromDateTime' => '11:01', 'timeZone' => new \DateTimeZone('Europe/Lisbon'), 'expectedIsDue' => false, ], ]; } /** @return iterable */ public static function toTimeZoneProvider(): iterable { yield 'same timezone' => [ static function (): array { $timeZone = new \DateTimeZone('Europe/Warsaw'); return [ 'now' => new \DateTimeImmutable( '13:59', $timeZone, ), 'toDateTime' => '14:00', 'timeZone' => $timeZone, 'expectedIsDue' => true, ]; }, ]; yield 'different timezones' => [ static fn (): array => [ 'now' => new \DateTimeImmutable( '17:01', new \DateTimeZone('Europe/Lisbon'), ), 'toDateTime' => '18:00', 'timeZone' => new \DateTimeZone('Europe/Warsaw'), 'expectedIsDue' => false, ], ]; } /** @return iterable */ public static function hourlyAtInvalidProvider(): iterable { yield 'minute below zero' => [ Faker::int(-100, -1), "Minute cannot be lower than '0'.", ]; yield 'minute above fifty nine' => [ Faker::int(60, 120), "Minute cannot be greater than '59'.", ]; } /** @return iterable */ public static function dateFromToProvider(): iterable { yield 'dateFrom, dateTo with format yyyy-mm-dd' => [ static fn (): array => [ 'dateFrom' => (new \DateTime('+' . \random_int(1, 59) . ' days'))->format('Y-m-d'), 'dateTo' => (new \DateTime('+' . \random_int(60, 120) . ' days'))->format('Y-m-d'), ], ]; yield 'dateFrom, dateTo with format H:i' => [ static fn (): array => [ 'dateFrom' => (new \DateTime('+' . \random_int(1, 29) . ' minutes'))->format('H:i'), 'dateTo' => (new \DateTime('+' . \random_int(30, 60) . ' minutes'))->format('H:i'), ], ]; yield 'dateFrom, dateTo with format yyyy-mm-dd hh:mm' => [ static fn (): array => [ 'dateFrom' => (new \DateTime('+' . \random_int(1, 59) . ' days +' . \random_int(1, 29) . ' minutes'))->format('Y-m-d H:i'), 'dateTo' => (new \DateTime('+' . \random_int(60, 120) . ' days +' . \random_int(30, 60) . ' minutes'))->format('Y-m-d H:i'), ], ]; } private function assertPreventOverlapping(?PersistingStoreInterface $store = null): void { $event = $this->createPreventOverlappingEvent($store); $event2 = $this->createPreventOverlappingEvent($store); $event->start(); self::assertFalse($event2->isDue(new \DateTimeZone('UTC'))); } private function createPreventOverlappingEvent(?PersistingStoreInterface $store = null): Event { $command = "php -r 'sleep(2);'"; $event = new Event(\uniqid('c', true), $command); $event->preventOverlapping($store); $event->everyMinute(); return $event; } private function setClockNow(\DateTimeImmutable $dateTime): void { $testClock = new TestClock($dateTime); $reflection = new \ReflectionClass(Event::class); $property = $reflection->getProperty('clock'); $property->setValue(null, $testClock); } private function isWindows(): bool { return DIRECTORY_SEPARATOR === '\\'; } private function createEvent(): Event { return new Event( \uniqid( 'c', true, ), 'php -i', ); } } ================================================ FILE: tests/Unit/Filesystem/FilesystemTest.php ================================================ getCwd()); } /** * @dataProvider fileExistsProvider * * @test */ public function file_exists_is_correct(string $path, bool $expectedExistence): void { $filesystem = new Filesystem(); self::assertSame($expectedExistence, $filesystem->fileExists($path)); } /** @test */ public function temp_directory_return_system_temp_directory(): void { $filesystem = new Filesystem(); self::assertSame(\sys_get_temp_dir(), $filesystem->tempDir()); } /** @test */ public function remove_directory_removes_directories_recursively(): void { $filesystem = new Filesystem(); $tempDir = \sys_get_temp_dir(); $rootPath = Path::fromStrings($tempDir, 'fs-tests'); $innerPath = Path::fromStrings($rootPath->toString(), 'inner'); $filePath = Path::fromStrings($innerPath->toString(), 'some-file.txt'); \mkdir( $innerPath->toString(), 0777, true ); \touch($filePath->toString()); $filesystem->removeDirectory($rootPath->toString()); self::assertDirectoryDoesNotExist($rootPath->toString()); } /** @test */ public function dump_file_writes_content_to_file(): void { $content = 'Some content'; $tempDir = \sys_get_temp_dir(); $filePath = Path::fromStrings($tempDir, 'dump-file.txt'); $filesystem = new Filesystem(); $filesystem->dumpFile($filePath->toString(), $content); self::assertStringEqualsFile($filePath->toString(), $content); \unlink($filePath->toString()); } /** @test */ public function create_directory_creates_directory_recursive(): void { $tempDir = \sys_get_temp_dir(); $rootDirectoryPath = Path::fromStrings($tempDir, 'crunz-test'); $directoryPath = Path::fromStrings( $rootDirectoryPath->toString(), 'deep', 'path', 'here' ); $filesystem = new Filesystem(); $filesystem->createDirectory($directoryPath->toString()); self::assertDirectoryExists($directoryPath->toString()); $filesystem->removeDirectory($rootDirectoryPath->toString()); } /** @test */ public function copy_files(): void { $content = 'Copy content'; $tempDir = \sys_get_temp_dir(); $rootDirectoryPath = Path::fromStrings($tempDir, 'copy-test'); \mkdir($rootDirectoryPath->toString()); $filePath = Path::fromStrings($rootDirectoryPath->toString(), 'file1.txt'); $targetFile = Path::fromStrings($rootDirectoryPath->toString(), 'file-copy.txt'); \file_put_contents($filePath->toString(), $content); $filesystem = new Filesystem(); $filesystem->copy($filePath->toString(), $targetFile->toString()); self::assertFileExists($targetFile->toString()); self::assertStringEqualsFile($targetFile->toString(), $content); $filesystem->removeDirectory($rootDirectoryPath->toString()); } /** @test */ public function project_root_directory(): void { $filesystem = new Filesystem(); self::assertSame($this->findProjectRootDirectory(), $filesystem->projectRootDirectory()); } /** @test */ public function read_content_return_file_content(): void { $filesystem = new Filesystem(); $content = $filesystem->readContent(__FILE__); self::assertStringContainsString('final class FilesystemTest extends TestCase', $content); } /** @test */ public function read_content_throws_exception_when_file_not_exists(): void { $path = Path::fromStrings(\sys_get_temp_dir(), 'wrong-file'); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage("File '{$path->toString()}' doesn't exists."); $filesystem = new Filesystem(); $filesystem->readContent($path->toString()); } /** * @return iterable * * @throws \Crunz\Exception\CrunzException */ public static function fileExistsProvider(): iterable { $tempFile = new TemporaryFile(); yield 'exists' => [ $tempFile->filePath(), true, // Param used to avoid GC $tempFile, ]; yield 'notExists' => [ '/some/wrong/path', false, ]; } private function findProjectRootDirectory(): string { $dir = $rootDir = \dirname(__DIR__); $path = Path::fromStrings($dir, 'composer.json'); while (!\file_exists($path->toString())) { if ($dir === \dirname($dir)) { return $rootDir; } $dir = \dirname($dir); $path = Path::fromStrings($dir, 'composer.json'); } return $dir; } } ================================================ FILE: tests/Unit/Finder/FinderTest.php ================================================ filesystem = $filesystem; $this->tasksDirectory = Path::fromStrings( $filesystem->tempDir(), '.crunz', 'finder-test' ); $this->filesystem->createDirectory($this->tasksDirectory->toString()); $this->fixtureDirectory = Path::fromStrings( \dirname(__DIR__, 2), 'resources', 'fixtures', 'finder', 'direct' ); } public function tearDown(): void { $tasksDirectory = $this->tasksDirectory; $this->filesystem ->removeDirectory($tasksDirectory->toString()); } /** * @test * * @dataProvider tasksProvider */ public function find_returns_spl_file_info_collection(string $suffix, Path ...$files): void { $this->createFiles(...$files); $tasksDirectory = $this->tasksDirectory; $finder = new Finder(); $foundFiles = $finder->find($tasksDirectory, $suffix); self::assertCount(\count($files), $foundFiles); self::assertContainsOnlyInstancesOf(\SplFileInfo::class, $foundFiles); } /** * @return iterable * * @throws \Crunz\Exception\CrunzException */ public static function tasksProvider(): iterable { $suffix = 'Here.php'; $taskOne = Path::fromStrings('TestHere.php'); $taskTwo = Path::fromStrings('first-level', 'OtherTestHere.php'); $taskThree = Path::fromStrings( 'first-level', 'second-level', 'TestHere.php' ); yield 'flat' => [$suffix, $taskOne]; yield 'firstLevel' => [ $suffix, $taskOne, $taskTwo, ]; yield 'secondLevel' => [ $suffix, $taskOne, $taskTwo, $taskThree, ]; } /** * @test */ public function find_files_in_symlinked_folder(): void { if ($this->isWindows()) { // Committed symlinks require extra steps to work on Windows // https://stackoverflow.com/questions/5917249/git-symlinks-in-windows self::markTestSkipped('Required Unix-based OS.'); } $fixtureDirectory = $this->fixtureDirectory; $directFile = Path::fromStrings($fixtureDirectory->toString(), 'directHere.php')->toString(); $symlinkFileDestination = Path::fromStrings($fixtureDirectory->toString(), 'symlink', 'symlinkHere.php')->toString(); $finder = new Finder(); $foundFiles = $finder->find($fixtureDirectory, 'Here.php'); self::assertCount(2, $foundFiles); self::assertArrayHasKey($directFile, $foundFiles); self::assertArrayHasKey($symlinkFileDestination, $foundFiles); } private function createFiles(Path ...$files): void { $tasksDirectory = $this->tasksDirectory; foreach ($files as $file) { $path = Path::fromStrings($tasksDirectory->toString(), $file->toString()); $content = \bin2hex(\random_bytes(8)); $this->filesystem ->dumpFile($path->toString(), $content); } } private function isWindows(): bool { return DIRECTORY_SEPARATOR === '\\'; } } ================================================ FILE: tests/Unit/HttpClient/StreamHttpClientTest.php ================================================ expectException(HttpClientException::class); $this->expectExceptionMessage($expectedExceptionMessage); // Act $client->ping($url); } } ================================================ FILE: tests/Unit/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpressionTestCase.php ================================================ createEnabledLoggerDecorator($spyLogger, $configuration); // Act $enabledLoggerDecorator->log($logLevel, Faker::words()); // Assert self::assertCount(0, $spyLogger->getLogs()); } /** @dataProvider enabledChannelProvider */ public function test_enabled_channels_log( ConfigurationInterface $configuration, string $logLevel, ): void { // Arrange $spyLogger = new SpyPsrLogger(); $enabledLoggerDecorator = $this->createEnabledLoggerDecorator($spyLogger, $configuration); // Act $enabledLoggerDecorator->log($logLevel, Faker::words()); // Assert self::assertCount(1, $spyLogger->getLogs()); } /** @return iterable */ public static function disabledChannelProvider(): iterable { yield 'output' => [ new FakeConfiguration(['log_output' => false]), LogLevel::INFO, ]; yield 'error' => [ new FakeConfiguration(['log_errors' => false]), LogLevel::ERROR, ]; } /** @return iterable */ public static function enabledChannelProvider(): iterable { yield 'output' => [ new FakeConfiguration(['log_output' => true]), LogLevel::INFO, ]; yield 'error' => [ new FakeConfiguration(['log_errors' => true]), LogLevel::ERROR, ]; } private function createEnabledLoggerDecorator( LoggerInterface $logger, ConfigurationInterface $configuration, ): EnabledLoggerDecorator { return new EnabledLoggerDecorator($logger, $configuration); } } ================================================ FILE: tests/Unit/Infrastructure/Psr/Logger/PsrStreamLoggerFactoryTest.php ================================================ createStreamLoggerFactory(); // Act $logger = $psrStreamLoggerFactory->create(new FakeConfiguration()); // Assert self::assertInstanceOf(EnabledLoggerDecorator::class, $logger); } private function createStreamLoggerFactory(): PsrStreamLoggerFactory { return new PsrStreamLoggerFactory( $this->createMock(Timezone::class), new TestClock(new \DateTimeImmutable()) ); } } ================================================ FILE: tests/Unit/Infrastructure/Psr/Logger/PsrStreamLoggerTest.php ================================================ createLogger($tempFile, $now); $logger->log($level, $message); self::assertSame( $this->formatLine( $now, $message, $level ), $tempFile->contents() ); } /** @dataProvider unsupportedLevelsProvider */ public function test_unsupported_levels_are_ignored(string $level): void { $message = Faker::words(5); $tempFile = new TemporaryFile(); $logger = $this->createLogger($tempFile); $logger->log($level, $message); self::assertEmpty($tempFile->contents()); } /** @dataProvider supportedLevelsProvider */ public function test_empty_context_is_ignored(string $level): void { $message = Faker::words(5); $now = new \DateTimeImmutable(); $tempFile = new TemporaryFile(); $logger = $this->createLogger( $tempFile, $now, null, true ); $logger->log($level, $message); self::assertSame( $this->formatLine( $now, $message, $level, true ), $tempFile->contents() ); } /** @dataProvider supportedLevelsProvider */ public function test_date_use_passed_time_zone(string $level): void { $timeZone = Faker::timeZone(); $message = Faker::words(5); $now = new \DateTimeImmutable(); $tempFile = new TemporaryFile(); $logger = $this->createLogger( $tempFile, $now, $timeZone, false, true ); $logger->log($level, $message); self::assertSame( $this->formatLine( $now, $message, $level, false, true, false, $timeZone ), $tempFile->contents() ); } /** @dataProvider supportedLevelsProvider */ public function test_logging_with_allowed_line_breaks(string $level): void { $message = Faker::words(1) . "\n" . Faker::words(1); $now = new \DateTimeImmutable(); $tempFile = new TemporaryFile(); $logger = $this->createLogger( $tempFile, $now, null, false, false, true ); $logger->log($level, $message); self::assertSame( $this->formatLine( $now, $message, $level, false, false, true ), $tempFile->contents() ); } /** @dataProvider supportedLevelsProvider */ public function test_logging_with_disallowed_line_breaks(string $level): void { $message = Faker::words(1) . "\n" . Faker::words(1); $now = new \DateTimeImmutable(); $tempFile = new TemporaryFile(); $logger = $this->createLogger($tempFile, $now); $logger->log($level, $message); self::assertSame( $this->formatLine( $now, $message, $level ), $tempFile->contents() ); } /** @return iterable */ public static function supportedLevelsProvider(): iterable { yield 'info' => ['info']; yield 'error' => ['error']; } /** @return iterable */ public static function unsupportedLevelsProvider(): iterable { yield 'emergency' => ['emergency']; yield 'alert' => ['alert']; yield 'critical' => ['critical']; yield 'warning' => ['warning']; yield 'notice' => ['notice']; yield 'debug' => ['debug']; } private function createLogger( TemporaryFile $temporaryFile, ?\DateTimeImmutable $now = null, ?\DateTimeZone $timeZone = null, bool $ignoreEmptyContext = false, bool $timezoneLog = false, bool $allowLineBreaks = false, ): LoggerInterface { $clock = new TestClock($now ?? Faker::dateTime()); return new PsrStreamLogger( $timeZone ?? Faker::timeZone(), $clock, $temporaryFile->filePath(), $temporaryFile->filePath(), $ignoreEmptyContext, $timezoneLog, $allowLineBreaks ); } private function formatLine( \DateTimeImmutable $date, string $message, string $level, bool $ignoreEmptyContext = false, bool $timeZoneLog = false, bool $allowLineBreaks = false, ?\DateTimeZone $timeZone = null, ): string { $context = '[] []'; if (true === $ignoreEmptyContext) { $context = ' '; } if (true === $timeZoneLog) { if (null === $timeZone) { throw new CrunzException("TimeZone must be specified to use 'timeZoneLog'."); } $date = $date->setTimezone($timeZone); } if (!$allowLineBreaks) { $message = \str_replace( [ "\r\n", "\r", "\n", ], ' ', $message ); } $dateString = $date->format('Y-m-d H:i:s'); $levelName = \mb_strtoupper($level); return "[{$dateString}] crunz.{$levelName}: {$message} {$context}" . PHP_EOL; } } ================================================ FILE: tests/Unit/InvokerTest.php ================================================ call( function () use (&$i) { return ++$i; } ); self::assertSame(2, $i); self::assertSame(2, $result); } /** @test */ public function call_executes_closure_with_params(): void { $i = 1; $invoker = new Invoker(); $invoker->call( function ($number) use (&$i): void { $i += $number; }, [2] ); self::assertSame(3, $i); } /** @test */ public function call_can_catch_output(): void { $invoker = new Invoker(); $result = $invoker->call( function (): void { echo 'Callback was called, nice.'; }, [], true ); self::assertSame('Callback was called, nice.', $result); } } ================================================ FILE: tests/Unit/Logger/ConsoleLoggerTest.php ================================================ = ConsoleLoggerInterface::VERBOSITY_NORMAL) ? 1 : 0; $mockSymfonyStyle = $this->mockSymfonyStyle($ioVerbosity); $mockSymfonyStyle ->expects(self::exactly($expectedCalls)) ->method('writeln') ; $consoleLogger = new ConsoleLogger($mockSymfonyStyle); $consoleLogger->normal('Some message'); } /** * @test * * @dataProvider verbosityProvider */ public function logger_writes_verbose_only_with_suitable_verbosity(int $ioVerbosity): void { $expectedCalls = ($ioVerbosity >= ConsoleLoggerInterface::VERBOSITY_VERBOSE) ? 1 : 0; $mockSymfonyStyle = $this->mockSymfonyStyle($ioVerbosity); $mockSymfonyStyle ->expects(self::exactly($expectedCalls)) ->method('writeln') ; $consoleLogger = new ConsoleLogger($mockSymfonyStyle); $consoleLogger->verbose('Some message'); } /** * @test * * @dataProvider verbosityProvider */ public function logger_writes_very_verbose_only_with_suitable_verbosity(int $ioVerbosity): void { $expectedCalls = ($ioVerbosity >= ConsoleLoggerInterface::VERBOSITY_VERY_VERBOSE) ? 1 : 0; $mockSymfonyStyle = $this->mockSymfonyStyle($ioVerbosity); $mockSymfonyStyle ->expects(self::exactly($expectedCalls)) ->method('writeln') ; $consoleLogger = new ConsoleLogger($mockSymfonyStyle); $consoleLogger->veryVerbose('Some message'); } /** * @test * * @dataProvider verbosityProvider */ public function logger_writes_debug_only_with_suitable_verbosity(int $ioVerbosity): void { $expectedCalls = ($ioVerbosity >= ConsoleLoggerInterface::VERBOSITY_DEBUG) ? 1 : 0; $mockSymfonyStyle = $this->mockSymfonyStyle($ioVerbosity); $mockSymfonyStyle ->expects(self::exactly($expectedCalls)) ->method('writeln') ; $consoleLogger = new ConsoleLogger($mockSymfonyStyle); $consoleLogger->debug('Some message'); } /** @return iterable> */ public static function verbosityProvider(): iterable { yield 'quiet' => [ConsoleLoggerInterface::VERBOSITY_QUIET]; yield 'normal' => [ConsoleLoggerInterface::VERBOSITY_NORMAL]; yield 'verbose' => [ConsoleLoggerInterface::VERBOSITY_VERBOSE]; yield 'veryVerbose' => [ConsoleLoggerInterface::VERBOSITY_VERY_VERBOSE]; yield 'debug' => [ConsoleLoggerInterface::VERBOSITY_DEBUG]; } /** @return MockObject&SymfonyStyle */ private function mockSymfonyStyle(int $ioVerbosity): object { $mock = $this->createMock(SymfonyStyle::class); $mock ->method('getVerbosity') ->willReturn($ioVerbosity) ; return $mock; } } ================================================ FILE: tests/Unit/Logger/LoggerFactoryTest.php ================================================ createLoggerFactory(); $loggerFactory->create(); $this->expectNotToPerformAssertions(); } public function test_logger_factory_creates_event_logger(): void { $loggerFactory = $this->createLoggerFactory(); $tempFile = new TemporaryFile(); $e = new Event('1', 'php foo'); $e->output = $tempFile->filePath(); $loggerFactory->createEvent($e->output); $this->expectNotToPerformAssertions(); } public function test_wrong_logger_class_throws_exception(): void { $loggerFactory = $this->createLoggerFactory(['logger_factory' => 'Wrong\Class']); $this->expectException(CrunzException::class); $this->expectExceptionMessage("Class 'Wrong\Class' does not exists."); $loggerFactory->create(); } /** @param array $configuration */ private function createLoggerFactory(array $configuration = []): LoggerFactory { $fakeConfiguration = new FakeConfiguration($configuration); $timeZoneProviderMock = $this->createMock(Timezone::class); return new LoggerFactory( $fakeConfiguration, $timeZoneProviderMock, new NullLogger(), new Clock() ); } } ================================================ FILE: tests/Unit/MailerTest.php ================================================ expectException(MailerException::class); $this->expectExceptionMessage("'mail' transport is no longer supported, please use 'smtp' or 'sendmail' transport."); $mailer = $this->createMailer('mail'); $mailer->send('Test', 'Message'); } private function createMailer(string $transport): Mailer { $configuration = new FakeConfiguration( [ 'mailer' => [ 'transport' => $transport, ], ] ); return new Mailer($configuration); } } ================================================ FILE: tests/Unit/Output/OutputFactoryTest.php ================================================ createOutput(); self::assertSame($expectedVerbosity, $output->getVerbosity()); } /** @return iterable */ public static function inputProvider(): iterable { yield 'quietShort' => [ self::createInput('-q'), OutputInterface::VERBOSITY_QUIET, ]; yield 'quietLong' => [ self::createInput('--quiet'), OutputInterface::VERBOSITY_QUIET, ]; yield 'normal' => [ self::createInput('--filter'), OutputInterface::VERBOSITY_NORMAL, ]; yield 'verbose' => [ self::createInput('-v'), OutputInterface::VERBOSITY_VERBOSE, ]; yield 'veryVerbose' => [ self::createInput('-vv'), OutputInterface::VERBOSITY_VERY_VERBOSE, ]; yield 'debug' => [ self::createInput('-vvv'), OutputInterface::VERBOSITY_DEBUG, ]; } private static function createInput(string $option): InputInterface { return new ArgvInput(['', $option]); } } ================================================ FILE: tests/Unit/Path/PathTest.php ================================================ expectException(CrunzException::class); $this->expectExceptionMessage('At least one part expected.'); Path::create([]); } /** @test */ public function parts_are_delimited_by_directory_separator(): void { $parts = [ 'home', 'crunz', 'bin', ]; $path = Path::create($parts); self::assertSame( \implode(DIRECTORY_SEPARATOR, $parts), $path->toString() ); } /** @test */ public function path_can_be_created_from_strings(): void { $parts = [ 'home', 'user', 'vendor', 'bin', 'crunz', ]; $path = Path::fromStrings(...$parts); self::assertSame( \implode(DIRECTORY_SEPARATOR, $parts), $path->toString() ); } /** @test */ public function doubled_directory_separator_is_normalized(): void { $parts = [ 'home' . DIRECTORY_SEPARATOR, 'user', ]; $path = Path::create($parts); self::assertSame( 'home' . DIRECTORY_SEPARATOR . 'user', $path->toString() ); } } ================================================ FILE: tests/Unit/Pingable.php ================================================ expectException(PingableException::class); $this->expectExceptionMessage("Url must be of type string, '{$type}' given."); $pingable = new Pingable(); $pingable->pingBefore($url); } /** * @test */ public function before_url_must_be_non_empty_string(): void { $this->expectException(PingableException::class); $this->expectExceptionMessage('Url cannot be empty.'); $pingable = new Pingable(); $pingable->pingBefore(''); } /** * @test */ public function after_url_must_be_non_empty_string(): void { $this->expectException(PingableException::class); $this->expectExceptionMessage('Url cannot be empty.'); $pingable = new Pingable(); $pingable->thenPing(''); } /** * @test * * @dataProvider nonStringProvider */ public function after_url_must_be_string(mixed $url): void { $type = \gettype($url); $this->expectException(PingableException::class); $this->expectExceptionMessage("Url must be of type string, '{$type}' given."); $pingable = new Pingable(); $pingable->thenPing($url); } /** @test */ public function get_ping_before_without_url_fails(): void { $this->expectException(PingableException::class); $this->expectExceptionMessage('PingBeforeUrl is empty.'); $pingable = new Pingable(); $pingable->getPingBeforeUrl(); } /** @test */ public function get_ping_after_without_url_fails(): void { $this->expectException(PingableException::class); $this->expectExceptionMessage('PingAfterUrl is empty.'); $pingable = new Pingable(); $pingable->getPingAfterUrl(); } /** @return iterable */ public static function nonStringProvider(): iterable { yield 'null' => [null]; yield 'array' => [[]]; yield 'object' => [new \stdClass()]; yield 'int' => [123]; yield 'float' => [6423.4324]; } } ================================================ FILE: tests/Unit/Process/ProcessTest.php ================================================ assertCommand($expectedCommandLine, $process); } private function assertCommand(string $expectedCommand, Process $process): void { $actualCommand = $process->commandLine(); if (IS_WINDOWS === true) { // Symfony Process may wrap values containing "=" in double quotes on Windows. $expectedCommand = \str_replace(["'", '"'], '', $expectedCommand); $actualCommand = \str_replace(["'", '"'], '', $actualCommand); } self::assertSame($expectedCommand, $actualCommand); } } ================================================ FILE: tests/Unit/Schedule/ScheduleFactoryTest.php ================================================ events([$event1, $event2]); $schedules = $factory->singleTaskSchedule(TaskNumber::fromString('1'), $schedule); /** @var Schedule $firstSchedule */ $firstSchedule = \reset($schedules); self::assertSame([$event1], $firstSchedule->events()); } /** @test */ public function single_task(): void { $factory = new ScheduleFactory(); $event1 = new Event(1, 'php -v'); $event2 = new Event(2, 'php -v'); $schedule = new Schedule(); $schedule->events([$event1, $event2]); $event = $factory->singleTask(TaskNumber::fromString('1'), $schedule); self::assertSame($event1, $event); } /** @test */ public function single_task_schedule_throws_exception_on_wrong_task_number(): void { $factory = new ScheduleFactory(); $event1 = new Event(1, 'php -v'); $schedule = new Schedule(); $schedule->events([$event1]); $this->expectException(TaskNotExistException::class); $this->expectExceptionMessage("Task with id '2' was not found. Last task id is '1'."); $factory->singleTaskSchedule(TaskNumber::fromString('2'), $schedule); } /** @test */ public function single_task_throws_exception_on_wrong_task_number(): void { $factory = new ScheduleFactory(); $event1 = new Event(1, 'php -v'); $schedule = new Schedule(); $schedule->events([$event1]); $this->expectException(TaskNotExistException::class); $this->expectExceptionMessage("Task with id '2' was not found. Last task id is '1'."); $factory->singleTask(TaskNumber::fromString('2'), $schedule); } } ================================================ FILE: tests/Unit/ScheduleTest.php ================================================ $command, 'parameters' => $parameters, 'expectedCommand' => $expectedCommand, ] = $paramsGenerator(); $schedule = new Schedule(); // Act $event = $schedule->run($command, $parameters); // Assert $this->assertCommand($expectedCommand, $event); } /** * @group legacy * * @dataProvider nonStringParametersProvider */ public function test_run_with_non_string_parameters(\Closure $paramsGenerator): void { // Arrange /** * @var string $command * @var string[] $parameters * @var string $expectedCommand */ [ 'command' => $command, 'parameters' => $parameters, 'expectedCommand' => $expectedCommand, ] = $paramsGenerator(); $schedule = new Schedule(); // Expect $this->expectDeprecation('Passing non-string parameters is deprecated since v3.3, convert all parameters to string.'); // Act $event = $schedule->run($command, $parameters); // Assert $this->assertCommand($expectedCommand, $event); } /** @return iterable */ public static function runProvider(): iterable { yield 'simple command' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => [], 'expectedCommand' => '/usr/bin/php', ], ]; yield 'command with inline argument' => [ static fn (): array => [ 'command' => '/usr/bin/php -v', 'parameters' => [], 'expectedCommand' => '/usr/bin/php -v', ], ]; yield 'command with argument' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['-v'], 'expectedCommand' => "/usr/bin/php '-v'", ], ]; yield 'command with option' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['--ini' => 'php.ini'], 'expectedCommand' => "/usr/bin/php '--ini' 'php.ini'", ], ]; yield 'command with mixed parameters' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['--ini' => 'php.ini', '-v'], 'expectedCommand' => "/usr/bin/php '--ini' 'php.ini' '-v'", ], ]; } /** @return iterable */ public static function nonStringParametersProvider(): iterable { yield 'boolean true parameter' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['-v' => true], 'expectedCommand' => "/usr/bin/php '-v' '1'", ], ]; yield 'boolean false parameter' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['-v' => false], 'expectedCommand' => "/usr/bin/php '-v' '0'", ], ]; yield 'int parameter' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['-v' => 4], 'expectedCommand' => "/usr/bin/php '-v' '4'", ], ]; yield 'float parameter' => [ static fn (): array => [ 'command' => '/usr/bin/php', 'parameters' => ['-v' => 3.14], 'expectedCommand' => "/usr/bin/php '-v' '3.14'", ], ]; } private function assertCommand(string $expectedCommand, Event $event): void { if (IS_WINDOWS === true) { $expectedCommand = \str_replace( "'", '', $expectedCommand, ); } self::assertSame($expectedCommand, $event->getCommand()); } } ================================================ FILE: tests/Unit/Service/AbstractClosureSerializerTestCase.php ================================================ new \stdClass(); $serializer = $this->createSerializer(); // Act $code = $serializer->closureCode($testClosure); // Assert self::assertSame('static fn (): \stdClass => new \stdClass()', $code); } abstract protected function createSerializer(): ClosureSerializerInterface; } ================================================ FILE: tests/Unit/Service/LaravelClosureSerializerTest.php ================================================ serializer = new LaravelClosureSerializer(); } public function test_serialize_simple_closure(): void { $closure = static function (): string { return 'hello'; }; $serialized = $this->serializer->serialize($closure); $result = $this->serializer->unserialize($serialized); self::assertSame('hello', $result()); } public function test_serialize_closure_with_use_variable(): void { $name = 'crunz'; $closure = static function () use ($name): string { return "hello {$name}"; }; $serialized = $this->serializer->serialize($closure); $result = $this->serializer->unserialize($serialized); self::assertSame('hello crunz', $result()); } public function test_serialize_closure_bound_to_object_with_closure_properties(): void { $runner = new TaskRunnerStub(); $closure = $runner->createTask(); $serialized = $this->serializer->serialize($closure); $result = $this->serializer->unserialize($serialized); self::assertSame('running daily-report', $result()); } /** * Regression test for laravel/serializable-closure#126. * * v2.0.9 skips walking properties of objects that implement __serialize, * leaving nested closures unwrapped and causing "Serialization of 'Closure' * is not allowed". */ public function test_serialize_closure_bound_to_object_with_serialize_and_closure_properties(): void { $runner = new SerializableTaskRunnerStub(); $closure = $runner->createTask(); $serialized = $this->serializer->serialize($closure); $result = $this->serializer->unserialize($serialized); self::assertSame('running daily-report', $result()); } public function test_closure_code_can_be_extracted(): void { $testClosure = static fn (): \stdClass => new \stdClass(); $code = $this->serializer->closureCode($testClosure); self::assertSame('static fn (): \stdClass => new \stdClass()', $code); } } ================================================ FILE: tests/Unit/Service/LaravelClosureSerializerTestCase.php ================================================ expectException(WrongTaskNumberException::class); $this->expectExceptionMessage('Passed task number is not string.'); TaskNumber::fromString($value); } /** * @test * * @dataProvider nonNumericProvider */ public function task_number_can_not_be_non_numeric_string(string $value): void { $this->expectException(WrongTaskNumberException::class); $this->expectExceptionMessage("Task number '{$value}' is not numeric."); TaskNumber::fromString($value); } /** * @test * * @dataProvider numericValueProvider */ public function task_number_can_be_created_with_numeric_string_value(string $value, int $expectedNumber): void { $taskNumber = TaskNumber::fromString($value); self::assertSame($expectedNumber, $taskNumber->asInt()); } /** @test */ public function array_index_is_one_step_lower(): void { $taskNumber = TaskNumber::fromString('14'); self::assertSame(13, $taskNumber->asArrayIndex()); } /** @return iterable */ public static function nonStringValueProvider(): iterable { yield 'null' => [null]; yield 'float' => [3.14]; yield 'int' => [7]; yield 'array' => [[]]; yield 'object' => [new \stdClass()]; } /** @return iterable */ public static function numericValueProvider(): iterable { yield 'int' => [ '155', 155, ]; yield 'float' => [ '3.14', 3, ]; } /** @return iterable */ public static function nonNumericProvider(): iterable { yield 'chars' => ['abc']; yield 'charsWithNumber' => ['1a2b3']; } } ================================================ FILE: tests/Unit/Task/TimezoneTest.php ================================================ expectException(EmptyTimezoneException::class); $taskTimezone = new Timezone( new FakeConfiguration(['timezone' => null]), new NullLogger() ); $taskTimezone->timezoneForComparisons(); } } ================================================ FILE: tests/Unit/Timezone/ProviderTest.php ================================================ defaultTimezone(); self::assertSame($timezoneName, $timezone->getName()); } } ================================================ FILE: tests/Unit/UserInterface/Cli/ClosureRunCommandTest.php ================================================ $returnValue; $command = $this->createCommand(); $input = $this->createInput($closure); $output = new NullOutput(); self::assertSame( 0, $command->run($input, $output) ); } /** @test */ public function command_is_hidden(): void { $command = $this->createCommand(); self::assertTrue($command->isHidden()); } /** @return iterable> */ public static function closureValueProvider(): iterable { yield '0' => [0]; yield '1' => [1]; } private function createInput(\Closure $closure): ArrayInput { $closureSerializer = $this->createClosureSerializer(); return new ArrayInput( [ 'closure' => \http_build_query( [ $closureSerializer->serialize($closure), ] ), ] ); } private function createCommand(): ClosureRunCommand { return new ClosureRunCommand($this->createClosureSerializer()); } } ================================================ FILE: tests/crunz.yml ================================================ source: tasks suffix: Tasks.php timezone: UTC timezone_log: false log_errors: false errors_log_file: ~ log_output: false output_log_file: ~ log_allow_line_breaks: false log_ignore_empty_context: false email_output: false email_errors: false mailer: transport: smtp recipients: sender_name: sender_email: smtp: host: ~ port: ~ username: ~ password: ~ encryption: ~ ================================================ FILE: tests/resources/fixtures/finder/direct/directHere.php ================================================ ================================================ FILE: tests/resources/fixtures/finder/symlink/symlinkHere.php ================================================ ================================================ FILE: tests/resources/tasks/ClosureTasks.php ================================================ run( function () use ($x): void { echo 'Closure output' . PHP_EOL; echo "Var: {$x}" . PHP_EOL; } ) ->description('Closure with output') ->everyMinute() ; return $scheduler; ================================================ FILE: tests/resources/tasks/CustomOutputTasks.php ================================================ run('php --help') ->description('Custom logging test') ->everyMinute() ->appendOutputTo('custom.log') ; return $scheduler; ================================================ FILE: tests/resources/tasks/FailTasks.php ================================================ run( function (): never { throw new RuntimeException('Task failed.'); } ) ->description('Task that will fail') ->everyMinute() ; return $scheduler; ================================================ FILE: tests/resources/tasks/NoOverlappingClosureTasks.php ================================================ run( function (): stdClass { \usleep(150 * 1000); // wait 150ms echo 'Done', PHP_EOL; return new stdClass(); } ) ->description('Closure with sleep') ->preventOverlapping() ->everyMinute() ; return $scheduler; ================================================ FILE: tests/resources/tasks/PhpVersionTasks.php ================================================ run('php -v') ->description('PHP version') ->everyMinute() ; return $scheduler; ================================================ FILE: tests/resources/tasks/WrongTasks.php ================================================ run(PHP_BINARY . ' -v') ->description('Show PHP version') ->everyMinute() ; // IMPORTANT: You must return the schedule object return $schedule;