Repository: omnilight/yii2-scheduling Branch: master Commit: f0c63f294312 Files: 18 Total size: 35.0 KB Directory structure: gitextract_zj2jjtom/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── php.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── docker-compose.yml ├── phpunit.xml.dist ├── src/ │ ├── Bootstrap.php │ ├── CallbackEvent.php │ ├── Event.php │ ├── Schedule.php │ └── ScheduleController.php └── tests/ ├── EventTest.php └── bootstrap.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ vendor .idea .dockerignore .git* .travis.yml composer.lock docker-compose.yml Dockerfile ================================================ FILE: .github/workflows/php.yml ================================================ name: PHP Composer on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: shivammathur/setup-php@v2 with: php-version: '5.4' extensions: mbstring - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite run: composer run-script test ================================================ FILE: .gitignore ================================================ .idea vendor composer.lock ================================================ FILE: .travis.yml ================================================ language: php php: - 5.4 env: global: - DEFAULT_COMPOSER_FLAGS="--prefer-dist --no-interaction --no-progress --optimize-autoloader" cache: directories: - vendor - $HOME/.composer/cache before_install: - phpenv config-rm xdebug.ini || true - travis_retry composer self-update - composer install $DEFAULT_COMPOSER_FLAGS script: vendor/bin/phpunit --verbose ================================================ FILE: Dockerfile ================================================ FROM composer FROM php:5.4-cli RUN apt-get update && \ apt-get install -y \ git \ unzip RUN docker-php-ext-install mbstring COPY --from=composer /usr/bin/composer /usr/bin/composer RUN composer global require hirak/prestissimo WORKDIR /app COPY composer.json . RUN composer install --prefer-dist --no-interaction --no-ansi COPY . . ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2015 Pavel Agalecky 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 ================================================ docker_build = (docker-compose build) build: $(call docker_build) test: $(call docker_build) && docker-compose run app composer run-script test ================================================ FILE: README.md ================================================ Schedule extension for Yii2 =========================== This extension is the port of Laravel's Schedule component (https://laravel.com/docs/master/scheduling#scheduling-artisan-commands) Installation ------------ The preferred way to install this extension is through [composer](http://getcomposer.org/download/). Either run ``` php composer.phar require omnilight/yii2-scheduling "*" ``` or add ```json "omnilight/yii2-scheduling": "*" ``` to the `require` section of your composer.json. Description ----------- This project is inspired by the Laravel's Schedule component and tries to bring it's simplicity to the Yii framework. Quote from Laravel's documentation: ``` In the past, developers have generated a Cron entry for each console command they wished to schedule. However, this is a headache. Your console schedule is no longer in source control, and you must SSH into your server to add the Cron entries. Let's make our lives easier. ``` After installation all you have to do is to put single line into crontab: ``` * * * * * php /path/to/yii yii schedule/run --scheduleFile=@path/to/schedule.php 1>> /dev/null 2>&1 ``` You can put your schedule into the `schedule.php` file, or add it withing bootstrapping of your extension or application Schedule examples ----------------- This extension is support all features of Laravel's Schedule, except environments and maintance mode. **Scheduling Closures** ```php $schedule->call(function() { // Do some task... })->hourly(); ``` **Scheduling Terminal Commands** ```php $schedule->exec('composer self-update')->daily(); ``` **Running command of your application** ```php $schedule->command('migrate')->cron('* * * * *'); ``` **Frequent Jobs** ```php $schedule->command('foo')->everyFiveMinutes(); $schedule->command('foo')->everyTenMinutes(); $schedule->command('foo')->everyThirtyMinutes(); ``` **Daily Jobs** ```php $schedule->command('foo')->daily(); ``` **Daily Jobs At A Specific Time (24 Hour Time)** ```php $schedule->command('foo')->dailyAt('15:00'); ``` **Twice Daily Jobs** ```php $schedule->command('foo')->twiceDaily(); ``` **Job That Runs Every Weekday** ```php $schedule->command('foo')->weekdays(); ``` **Weekly Jobs** ```php $schedule->command('foo')->weekly(); // Schedule weekly job for specific day (0-6) and time... $schedule->command('foo')->weeklyOn(1, '8:00'); ``` **Monthly Jobs** ```php $schedule->command('foo')->monthly(); ``` **Job That Runs On Specific Days** ```php $schedule->command('foo')->mondays(); $schedule->command('foo')->tuesdays(); $schedule->command('foo')->wednesdays(); $schedule->command('foo')->thursdays(); $schedule->command('foo')->fridays(); $schedule->command('foo')->saturdays(); $schedule->command('foo')->sundays(); ``` **Only Allow Job To Run When Callback Is True** ```php $schedule->command('foo')->monthly()->when(function() { return true; }); ``` **E-mail The Output Of A Scheduled Job** ```php $schedule->command('foo')->sendOutputTo($filePath)->emailOutputTo('foo@example.com'); ``` **Preventing Task Overlaps** ```php $schedule->command('foo')->withoutOverlapping(); ``` Used by default yii\mutex\FileMutex or 'mutex' application component (http://www.yiiframework.com/doc-2.0/yii-mutex-mutex.html) **Running Tasks On One Server** >To utilize this feature, you must config mutex in the application component, except the FileMutex: `yii\mutex\MysqlMutex`,`yii\mutex\PgsqlMutex`,`yii\mutex\OracleMutex` or `yii\redis\Mutex`. In addition, all servers must be communicating with the same central db/cache server. Below shows the redis mutex demo: ```php 'components' => [ 'mutex' => [ 'class' => 'yii\redis\Mutex', 'redis' => [ 'hostname' => 'localhost', 'port' => 6379, 'database' => 0, ] ], ], ``` ```php $schedule->command('report:generate') ->fridays() ->at('17:00') ->onOneServer(); ``` How to use this extension in your application? ---------------------------------------------- You should create the following file under `@console/config/schedule.php` (note: you can create file with any name and extension and anywere on your server, simpli ajust the name of the scheduleFile in the command below): ```php exec('ls')->everyFiveMinutes(); // This command will execute migration command of your application every hour $schedule->command('migrate')->hourly(); // This command will call callback function every day at 10:00 $schedule->call(function(\yii\console\Application $app) { // Some code here... })->dailyAt('10:00'); ``` Next your should add the following command to your crontab: ``` * * * * * php /path/to/yii yii schedule/run --scheduleFile=@console/config/schedule.php 1>> /dev/null 2>&1 ``` That's all! Now all your cronjobs will be runned as configured in your schedule.php file. How to use this extension in your own extension? ------------------------------------------------ First of all, you should include dependency to the `omnilight\yii2-scheduling` into your composer.json: ``` ... 'require': { "omnilight/yii2-schedule": "*" } ... ``` Next you should create bootstrapping class for your extension, as described in the http://www.yiiframework.com/doc-2.0/guide-structure-extensions.html#bootstrapping-classes Place into your bootstrapping method the following code: ```php public function bootstrap(Application $app) { if ($app instanceof \yii\console\Application) { if ($app->has('schedule')) { /** @var omnilight\scheduling\Schedule $schedule */ $schedule = $app->get('schedule'); // Place all your shedule command below $schedule->command('my-extension-command')->dailyAt('12:00'); } } } ``` Add to the README of your extension info for the user to register `schedule` component for the application and add `schedule/run` command to the crontab as described upper. Using `schedule` component -------------------------- You do not have to use `schedule` component directly or define it in your application if you use schedule only in your application (and do not want to give ability for extensions to register they own cron jobs). But if you what to give extensions ability to register cronjobs, you should define `schedule` component in the application config: ```php 'schedule' => 'omnilight\scheduling\Schedule', ``` Using addition functions ------------------------ If you want to use `thenPing` method of the Event, you should add the following string to the `composer.json` of your app: ``` "guzzlehttp/guzzle": "~5.0" ``` Note about timezones -------------------- Please note, that this is PHP extension, so it use timezone defined in php config or in your Yii's configuration file, so set them correctly. ================================================ FILE: composer.json ================================================ { "name": "omnilight/yii2-scheduling", "description": "Scheduling extension for Yii2 framework", "keywords": [ "yii", "scheduling", "cron" ], "minimum-stability": "dev", "authors": [ { "name": "Pavel Agalecky", "email": "pavel.agalecky@gmail.com" } ], "type": "yii2-extension", "require": { "php": ">=5.4.0", "yiisoft/yii2": "2.0.*", "symfony/process": "2.6.* || 3.* || 4.* || ^5.0", "dragonmantank/cron-expression": "1.*" }, "require-dev": { "phpunit/phpunit": "4.8.36" }, "suggest": { "guzzlehttp/guzzle": "Required to use the thenPing method on schedules (~5.0)." }, "autoload": { "psr-4": { "omnilight\\scheduling\\": "src" } }, "extra": { "bootstrap": "omnilight\\scheduling\\Bootstrap" }, "scripts": { "test": "vendor/bin/phpunit" }, "config": { "allow-plugins": { "yiisoft/yii2-composer": true } } } ================================================ FILE: docker-compose.yml ================================================ version: '3' services: app: build: . ================================================ FILE: phpunit.xml.dist ================================================ ./tests ================================================ FILE: src/Bootstrap.php ================================================ controllerMap['schedule'])) { $app->controllerMap['schedule'] = 'omnilight\scheduling\ScheduleController'; } } } } ================================================ FILE: src/CallbackEvent.php ================================================ callback = $callback; $this->parameters = $parameters; $this->_mutex = $mutex; if (!empty($config)) { Yii::configure($this, $config); } if ( ! is_string($this->callback) && ! is_callable($this->callback)) { throw new InvalidParamException( "Invalid scheduled callback event. Must be string or callable." ); } } /** * Run the given event. * * @param Application $app * @return mixed */ public function run(Application $app) { $this->trigger(self::EVENT_BEFORE_RUN); $response = call_user_func_array($this->callback, array_merge($this->parameters, [$app])); parent::callAfterCallbacks($app); $this->trigger(self::EVENT_AFTER_RUN); return $response; } /** * Do not allow the event to overlap each other. * * @return $this * @throws InvalidParamException */ public function withoutOverlapping() { if (empty($this->_description)) { throw new InvalidParamException( "A scheduled event name is required to prevent overlapping. Use the 'description' method before 'withoutOverlapping'." ); } return parent::withoutOverlapping(); } /** * Get the mutex name for the scheduled command. * * @return string */ protected function mutexName() { return 'framework/schedule-' . sha1($this->_description); } /** * Get the summary of the event for display. * * @return string */ public function getSummaryForDisplay() { if (is_string($this->_description)) return $this->_description; return is_string($this->callback) ? $this->callback : 'Closure'; } } ================================================ FILE: src/Event.php ================================================ '; /** * The array of callbacks to be run after the event is finished. * * @var array */ protected $_afterCallbacks = []; /** * The human readable description of the event. * * @var string */ protected $_description; /** * The mutex implementation. * * @var \yii\mutex\Mutex */ protected $_mutex; /** * Decide if errors will be displayed. * * @var bool */ protected $_omitErrors = false; /** * Create a new event instance. * * @param Mutex $mutex * @param string $command * @param array $config */ public function __construct(Mutex $mutex, $command, $config = []) { $this->command = $command; $this->_mutex = $mutex; $this->_output = $this->getDefaultOutput(); parent::__construct($config); } /** * Run the given event. * @param Application $app */ public function run(Application $app) { $this->trigger(self::EVENT_BEFORE_RUN); if (count($this->_afterCallbacks) > 0) { $this->runCommandInForeground($app); } else { $this->runCommandInBackground($app); } $this->trigger(self::EVENT_AFTER_RUN); } /** * Get the mutex name for the scheduled command. * * @return string */ protected function mutexName() { return 'framework/schedule-' . sha1($this->_expression . $this->command); } /** * Run the command in the foreground. * * @param Application $app */ protected function runCommandInForeground(Application $app) { $command = trim($this->buildCommand(), '& '); $cwd = dirname($app->request->getScriptFile()); if (method_exists(Process::class, 'fromShellCommandline')) { $process = Process::fromShellCommandline($command, $cwd, null, null, null); } else { $process = (new Process($command, $cwd, null, null, null)); } $process->run(); $this->callAfterCallbacks($app); } /** * Build the comand string. * * @return string */ public function buildCommand() { $command = $this->command . $this->_redirect . $this->_output . (($this->_omitErrors) ? ' 2>&1 &' : ' &'); return $this->_user ? 'sudo -u ' . $this->_user . ' ' . $command : $command; } /** * Call all of the "after" callbacks for the event. * * @param Application $app */ protected function callAfterCallbacks(Application $app) { foreach ($this->_afterCallbacks as $callback) { call_user_func($callback, $app); } } /** * Run the command in the background using exec. * * @param Application $app */ protected function runCommandInBackground(Application $app) { chdir(dirname($app->request->getScriptFile())); exec($this->buildCommand()); } /** * Determine if the given event should run based on the Cron expression. * * @param Application $app * @return bool */ public function isDue(Application $app) { return $this->expressionPasses() && $this->filtersPass($app); } /** * Determine if the Cron expression passes. * * @return bool */ protected function expressionPasses() { $date = new \DateTime('now'); if ($this->_timezone) { $date->setTimezone($this->_timezone); } return CronExpression::factory($this->_expression)->isDue($date); } /** * Determine if the filters pass for the event. * * @param Application $app * @return bool */ protected function filtersPass(Application $app) { if ($this->_filter && !call_user_func($this->_filter, $app) || $this->_reject && call_user_func($this->_reject, $app) ) { return false; } return true; } /** * Schedule the event to run hourly. * * @return $this */ public function hourly() { return $this->cron('0 * * * * *'); } /** * The Cron expression representing the event's frequency. * * @param string $expression * @return $this */ public function cron($expression) { $this->_expression = $expression; return $this; } /** * Schedule the event to run daily. * * @return $this */ public function daily() { return $this->cron('0 0 * * * *'); } /** * Schedule the command at a given time. * * @param string $time * @return $this */ public function at($time) { return $this->dailyAt($time); } /** * Schedule the event to run daily at a given time (10:00, 19:30, etc). * * @param string $time * @return $this */ public function dailyAt($time) { $segments = explode(':', $time); return $this->spliceIntoPosition(2, (int)$segments[0]) ->spliceIntoPosition(1, count($segments) == 2 ? (int)$segments[1] : '0'); } /** * Splice the given value into the given position of the expression. * * @param int $position * @param string $value * @return Event */ protected function spliceIntoPosition($position, $value) { $segments = explode(' ', $this->_expression); $segments[$position - 1] = $value; return $this->cron(implode(' ', $segments)); } /** * Schedule the event to run twice daily. * * @return $this */ public function twiceDaily() { return $this->cron('0 1,13 * * * *'); } /** * Schedule the event to run only on weekdays. * * @return $this */ public function weekdays() { return $this->spliceIntoPosition(5, '1-5'); } /** * Schedule the event to run only on Mondays. * * @return $this */ public function mondays() { return $this->days(1); } /** * Set the days of the week the command should run on. * * @param array|int $days * @return $this */ public function days($days) { $days = is_array($days) ? $days : func_get_args(); return $this->spliceIntoPosition(5, implode(',', $days)); } /** * Schedule the event to run only on Tuesdays. * * @return $this */ public function tuesdays() { return $this->days(2); } /** * Schedule the event to run only on Wednesdays. * * @return $this */ public function wednesdays() { return $this->days(3); } /** * Schedule the event to run only on Thursdays. * * @return $this */ public function thursdays() { return $this->days(4); } /** * Schedule the event to run only on Fridays. * * @return $this */ public function fridays() { return $this->days(5); } /** * Schedule the event to run only on Saturdays. * * @return $this */ public function saturdays() { return $this->days(6); } /** * Schedule the event to run only on Sundays. * * @return $this */ public function sundays() { return $this->days(0); } /** * Schedule the event to run weekly. * * @return $this */ public function weekly() { return $this->cron('0 0 * * 0 *'); } /** * Schedule the event to run weekly on a given day and time. * * @param int $day * @param string $time * @return $this */ public function weeklyOn($day, $time = '0:0') { $this->dailyAt($time); return $this->spliceIntoPosition(5, $day); } /** * Schedule the event to run monthly. * * @return $this */ public function monthly() { return $this->cron('0 0 1 * * *'); } /** * Schedule the event to run yearly. * * @return $this */ public function yearly() { return $this->cron('0 0 1 1 * *'); } /** * Schedule the event to run every minute. * * @return $this */ public function everyMinute() { return $this->cron('* * * * * *'); } /** * Schedule the event to run every N minutes. * * @param int|string $minutes * @return $this */ public function everyNMinutes($minutes) { return $this->cron('*/'.$minutes.' * * * * *'); } /** * Schedule the event to run every five minutes. * * @return $this */ public function everyFiveMinutes() { return $this->everyNMinutes(5); } /** * Schedule the event to run every ten minutes. * * @return $this */ public function everyTenMinutes() { return $this->everyNMinutes(10); } /** * Schedule the event to run every thirty minutes. * * @return $this */ public function everyThirtyMinutes() { return $this->cron('0,30 * * * * *'); } /** * Set the timezone the date should be evaluated on. * * @param \DateTimeZone|string $timezone * @return $this */ public function timezone($timezone) { $this->_timezone = $timezone; return $this; } /** * Set which user the command should run as. * * @param string $user * @return $this */ public function user($user) { $this->_user = $user; return $this; } /** * Set if errors should be displayed * * @param bool $omitErrors * @return $this */ public function omitErrors($omitErrors = false) { $this->_omitErrors = $omitErrors; return $this; } /** * Do not allow the event to overlap each other. * * @return $this */ public function withoutOverlapping() { return $this->then(function() { $this->_mutex->release($this->mutexName()); })->skip(function() { return !$this->_mutex->acquire($this->mutexName()); }); } /** * Allow the event to only run on one server for each cron expression. * * @return $this */ public function onOneServer() { if ($this->_mutex instanceof FileMutex) { throw new InvalidConfigException('You must config mutex in the application component, except the FileMutex.'); } return $this->withoutOverlapping(); } /** * Register a callback to further filter the schedule. * * @param \Closure $callback * @return $this */ public function when(\Closure $callback) { $this->_filter = $callback; return $this; } /** * Register a callback to further filter the schedule. * * @param \Closure $callback * @return $this */ public function skip(\Closure $callback) { $this->_reject = $callback; return $this; } /** * Send the output of the command to a given location. * * @param string $location * @return $this */ public function sendOutputTo($location) { $this->_redirect = ' > '; $this->_output = $location; return $this; } /** * Append the output of the command to a given location. * * @param string $location * @return $this */ public function appendOutputTo($location) { $this->_redirect = ' >> '; $this->_output = $location; return $this; } /** * E-mail the results of the scheduled operation. * * @param array $addresses * @return $this * * @throws \LogicException */ public function emailOutputTo($addresses) { if (is_null($this->_output) || $this->_output == $this->getDefaultOutput()) { throw new InvalidCallException("Must direct output to a file in order to e-mail results."); } $addresses = is_array($addresses) ? $addresses : func_get_args(); return $this->then(function (Application $app) use ($addresses) { $this->emailOutput($app->mailer, $addresses); }); } /** * Register a callback to be called after the operation. * * @param \Closure $callback * @return $this */ public function then(\Closure $callback) { $this->_afterCallbacks[] = $callback; return $this; } /** * E-mail the output of the event to the recipients. * * @param MailerInterface $mailer * @param array $addresses */ protected function emailOutput(MailerInterface $mailer, $addresses) { $textBody = file_get_contents($this->_output); if (trim($textBody) != '' ) { $mailer->compose() ->setTextBody($textBody) ->setSubject($this->getEmailSubject()) ->setTo($addresses) ->send(); } } /** * Get the e-mail subject line for output results. * * @return string */ protected function getEmailSubject() { if ($this->_description) { return 'Scheduled Job Output (' . $this->_description . ')'; } return 'Scheduled Job Output'; } /** * Register a callback to the ping a given URL after the job runs. * * @param string $url * @return $this */ public function thenPing($url) { return $this->then(function () use ($url) { (new HttpClient)->get($url); }); } /** * Set the human-friendly description of the event. * * @param string $description * @return $this */ public function description($description) { $this->_description = $description; return $this; } /** * 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 Cron expression for the event. * * @return string */ public function getExpression() { return $this->_expression; } public function getDefaultOutput() { if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { return 'NUL'; } else { return '/dev/null'; } } } ================================================ FILE: src/Schedule.php ================================================ _mutex = Yii::$app->has('mutex') ? Yii::$app->get('mutex') : (new FileMutex()); parent::__construct($config); } /** * Add a new callback event to the schedule. * * @param string $callback * @param array $parameters * @return Event */ public function call($callback, array $parameters = array()) { $this->_events[] = $event = new CallbackEvent($this->_mutex, $callback, $parameters); return $event; } /** * Add a new cli command event to the schedule. * * @param string $command * @return Event */ public function command($command) { return $this->exec(PHP_BINARY . ' ' . $this->cliScriptName . ' ' . $command); } /** * Add a new command event to the schedule. * * @param string $command * @return Event */ public function exec($command) { $this->_events[] = $event = new Event($this->_mutex, $command); return $event; } public function getEvents() { return $this->_events; } /** * Get all of the events on the schedule that are due. * * @param \yii\base\Application $app * @return Event[] */ public function dueEvents(Application $app) { return array_filter($this->_events, function(Event $event) use ($app) { return $event->isDue($app); }); } } ================================================ FILE: src/ScheduleController.php ================================================ has($this->schedule)) { $this->schedule = Instance::ensure($this->schedule, Schedule::className()); } else { $this->schedule = \Yii::createObject(Schedule::className()); } parent::init(); } public function actionRun() { $this->importScheduleFile(); $events = $this->schedule->dueEvents(\Yii::$app); foreach ($events as $event) { $event->omitErrors($this->omitErrors); $this->stdout('Running scheduled command: '.$event->getSummaryForDisplay()."\n"); $event->run(\Yii::$app); } if (count($events) === 0) { $this->stdout("No scheduled commands are ready to run.\n"); } } protected function importScheduleFile() { if ($this->scheduleFile === null) { return; } $scheduleFile = \Yii::getAlias($this->scheduleFile); if (file_exists($scheduleFile) == false) { $this->stderr('Can not load schedule file '.$this->scheduleFile."\n"); return; } $schedule = $this->schedule; call_user_func(function() use ($schedule, $scheduleFile) { include $scheduleFile; }); } } ================================================ FILE: tests/EventTest.php ================================================ /dev/null'], [false, 'php -i', '/my folder/foo.log', 'php -i > /my folder/foo.log'], [true, 'php -i', '/dev/null', 'php -i > /dev/null 2>&1 &'], [true, 'php -i', '/my folder/foo.log', 'php -i > /my folder/foo.log 2>&1 &'], ]; } /** * @dataProvider buildCommandData * @param bool $omitErrors * @param string $command * @param string $outputTo * @param string $result */ public function testBuildCommandSendOutputTo($omitErrors, $command, $outputTo, $result) { $event = new Event($this->getMock(Mutex::className()), $command); $event->omitErrors($omitErrors); $event->sendOutputTo($outputTo); $this->assertSame($result, $event->buildCommand()); } } ================================================ FILE: tests/bootstrap.php ================================================