├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── composer.json ├── images ├── demo.cast ├── demo.svg ├── exit.svg └── watcher.gif ├── php-watcher ├── php-watcher.yml.dist ├── phpunit.xml.dist ├── psalm.xml ├── src ├── Config │ ├── Builder.php │ ├── Config.php │ ├── InputExtractor.php │ ├── InvalidConfigFileContents.php │ └── WatchList.php ├── ConsoleApplication.php ├── Filesystem │ ├── ChangesListener.php │ ├── ResourceWatcherBased │ │ ├── ChangesListener.php │ │ └── ResourceWatcherBuilder.php │ └── WatchPath.php ├── ProcessRunner.php ├── Screen │ ├── Screen.php │ ├── SpinnerFactory.php │ └── VoidSpinner.php ├── Watcher.php └── WatcherCommand.php └── tests ├── Feature ├── ChangesListenerTest.php ├── ConfigTest.php ├── Helper │ ├── Filesystem.php │ ├── WatcherRunner.php │ ├── WatcherTestCase.php │ ├── WithFilesystem.php │ └── php-watcher.yml.dist ├── IgnoreFilesTest.php ├── RunScriptTest.php ├── ScriptExitTest.php ├── SignalTest.php ├── WatchDirectoriesTest.php └── WatchFilesTest.php ├── Unit ├── SpinnerFactoryTest.php └── WatchPathTest.php └── fixtures └── .gitkeep /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | clover.xml 3 | .phpunit.result.cache 4 | vendor/ 5 | composer.lock 6 | tests/fixtures/*.* 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | jobs: 4 | include: 5 | - stage: "PHP7.4 - lowest" 6 | php: 7.4 7 | script: 8 | - composer update -n --prefer-dist --no-suggest 9 | - composer dump-autoload 10 | - composer ci:tests 11 | - composer ci:php:psalm 12 | 13 | - stage: "PHP8.0 - highest" 14 | php: 8.0 15 | script: 16 | - composer update -n --prefer-dist --no-suggest 17 | - composer dump-autoload 18 | - composer ci:tests 19 | - composer ci:php:psalm 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.6.0 (2020-05-11) 2 | * Fix: don't use child process for resource watching 3 | 4 | ## 0.5.2 (2019-12-07) 5 | * Fix: use predefined const for PHP binary [#59](https://github.com/seregazhuk/php-watcher/pull/59) 6 | * Fix: increase dependency options [#58](https://github.com/seregazhuk/php-watcher/pull/58) by @mmoreram 7 | 8 | ## 0.5.1 (2019-11-03) 9 | * Fix: ability to disable the spinner [#48](https://github.com/seregazhuk/php-watcher/pull/48) 10 | 11 | ## 0.5.0 (2019-11-01) 12 | * Feature: watching whether the script is alive [#47](https://github.com/seregazhuk/php-watcher/pull/47) 13 | 14 | ## 0.4.3 (2019-10-28) 15 | * Improved: reused a package for spinner implementation [#45](https://github.com/seregazhuk/php-watcher/pull/45) 16 | * Fix: Output stderr of the underlying script [#44](https://github.com/seregazhuk/php-watcher/pull/44) 17 | * Improved: Improve file changes watching [#42](https://github.com/seregazhuk/php-watcher/pull/42) 18 | 19 | ## 0.4.2 (2019-10-18) 20 | * Fix: Make script argument required via cli [#36](https://github.com/seregazhuk/php-watcher/pull/36) 21 | * Fix: Move symfony process to dev dependencies [#34](https://github.com/seregazhuk/php-watcher/pull/34) 22 | * Fix: improvements in spinner rendering [#32](https://github.com/seregazhuk/php-watcher/pull/32) 23 | 24 | ## 0.4.1 (2019-10-15) 25 | * Fix: CLI empty options override values from config file [#30](https://github.com/seregazhuk/php-watcher/pull/30) 26 | 27 | ## 0.4.0 (2019-10-15) 28 | * Fix: allow to listen to signals when running inside the docker container [#27](https://github.com/seregazhuk/php-watcher/pull/27) 29 | * Feature: send custom signals to restart the app [#27](https://github.com/seregazhuk/php-watcher/pull/27) 30 | 31 | ## 0.3.1 (2019-10-10) 32 | * Fix: autoload path inside watcher.php fixed for composer project 33 | installation ([#16](https://github.com/seregazhuk/php-watcher/pull/16)) by [gorbunov](https://github.com/gorbunov) 34 | * Fix: custom spinner implementation ([#19](https://github.com/seregazhuk/php-watcher/pull/19)) 35 | * Fix: restore screen cursor when interrupting the script ([#20](https://github.com/seregazhuk/php-watcher/pull/20)) 36 | 37 | ## 0.3.0 (2019-10-08) 38 | 39 | * Feature: add spinner to output ([#11](https://github.com/seregazhuk/php-watcher/pull/11)) 40 | * Feature / BC break: move to PHP 7.2 ([#11](https://github.com/seregazhuk/php-watcher/pull/11)) 41 | * Fix: make it truly async ([#10](https://github.com/seregazhuk/php-watcher/pull/10)) 42 | 43 | ## 0.2.0 (2019-10-04) 44 | 45 | * Feature: allow to specify config file via CLI option ([#6](https://github.com/seregazhuk/php-watcher/pull/6)) 46 | * Fix: default CLI options override config values ([#2](https://github.com/seregazhuk/php-watcher/pull/4)) 47 | 48 | ## 0.1.0 (2019-10-03) 49 | 50 | * First tagged release 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP-watcher 2 | 3 | [![PHP Version](https://img.shields.io/packagist/php-v/seregazhuk/php-watcher.svg)](https://php.net/) 4 | [![Build Status](https://travis-ci.org/seregazhuk/php-watcher.svg?branch=master)](https://travis-ci.org/seregazhuk/php-watcher) 5 | [![Total Downloads](https://poser.pugx.org/seregazhuk/php-watcher/downloads)](https://packagist.org/packages/seregazhuk/php-watcher) 6 | 7 | [![Latest Stable Version](https://poser.pugx.org/seregazhuk/php-watcher/v/stable)](https://packagist.org/packages/seregazhuk/php-watcher) 8 | [![Latest Version](https://img.shields.io/packagist/v/seregazhuk/php-watcher.svg)](https://packagist.org/packages/seregazhuk/php-watcher) 9 | [![Latest Unstable Version](https://poser.pugx.org/seregazhuk/php-watcher/v/unstable)](https://packagist.org/packages/seregazhuk/php-watcher) 10 | 11 | [![License](https://poser.pugx.org/seregazhuk/php-watcher/license)](https://packagist.org/packages/seregazhuk/php-watcher) 12 | 13 | ![watcher logo](images/watcher.gif) 14 | 15 | 16 | PHP-watcher helps develop long-running PHP applications by automatically 17 | restarting them when file changes in the directory are detected. 18 | 19 | Here's how it looks like: 20 | 21 | ![watcher screenshot](images/demo.svg) 22 | 23 | PHP-watcher does not require any additional changes to your code or method of 24 | development. `php-watcher` is a replacement wrapper for `php`, to use PHP 25 | -watcher replace the word `php` in the command line when executing your script. 26 | 27 | **Table of contents** 28 | 29 | * [Installation](#installation) 30 | * [Usage](#usage) 31 | * [Config files](#config-files) 32 | * [Monitoring multiple directories](#monitoring-multiple-directories) 33 | * [Specifying extension watch list](#specifying-extension-watch-list) 34 | * [Ignoring files](#ignoring-files) 35 | * [Delaying restarting](#delaying-restarting) 36 | * [Default executable](#default-executable) 37 | * [Gracefully reloading down your script](#gracefully-reloading-down-your-script) 38 | * [Automatic restart](#automatic-restart) 39 | * [Spinner](#spinner) 40 | 41 | ## Installation 42 | 43 | You can install this package globally like this: 44 | 45 | ```bash 46 | composer global require seregazhuk/php-watcher 47 | ``` 48 | 49 | After that phpunit-watcher watch can be run in any directory on your system. 50 | 51 | Alternatively, you can install the package locally as a dev dependency in your 52 | project: 53 | 54 | ```bash 55 | composer require seregazhuk/php-watcher --dev 56 | ``` 57 | Locally installed you can run it with `vendor/bin/php-watcher`. 58 | 59 | ## Usage 60 | 61 | All the examples assume you've installed the package globally. If you opted for the local installation prepend `vendor/bin/` everywhere where `php-watcher` is mentioned. 62 | 63 | PHP-watcher wraps your application, so you can pass all the arguments you 64 | would normally pass to your app: 65 | 66 | ```bash 67 | php-watcher [your php app] 68 | ``` 69 | 70 | Using PHP-Watcher is simple. If your application accepts a host and port as the 71 | arguments, I can start it using option `--arguments`: 72 | 73 | ```bash 74 | php-watcher server.php --arguments localhost --arguments 8080 75 | ``` 76 | 77 | Any output from this script is prefixed with `[php-watcher]`, otherwise all 78 | output from your application, errors included, will be echoed out as expected. 79 | 80 | ## Config files 81 | 82 | PHP-Watcher supports customization of its behavior with config files. The 83 | file for options may be named `.php-watcher.yml`, `php-watcher.yml` or `php-watcher.yml.dist`. 84 | The tool will look for a file in the current working directory in that order. 85 | An alternative local configuration file can be specified with the `--config 86 | ` option. 87 | 88 | The specificity is as follows, so that a command line argument will always override the config file settings: 89 | 90 | - command line arguments 91 | - local config 92 | 93 | A config file can take any of the command line arguments, for example: 94 | 95 | ```yml 96 | watch: 97 | - src 98 | - config 99 | extensions: 100 | - php 101 | - yml 102 | ignore: 103 | - tests 104 | ``` 105 | 106 | ## Monitoring multiple directories 107 | 108 | By default, PHP-Watcher monitors the current working directory. If you want to 109 | take control of that option, use the `--watch` option to add specific paths: 110 | 111 | ```bash 112 | php-watcher --watch src --watch config server.php 113 | ``` 114 | 115 | Now PHP-Watcher will only restart if there are changes in the `./src` or 116 | `./config 117 | ` directories. By default traverses sub-directories, so there's no 118 | need to explicitly include them. 119 | 120 | ## Specifying extension watch list 121 | 122 | By default, PHP-Watcher looks for files with the `.php` extension. If you use 123 | the `--ext` option and monitor `app,yml` PHP-Watcher will monitor files with 124 | the extension of `.php` and `.yml`: 125 | 126 | ```bash 127 | php-watcher server.php --ext=php,yml 128 | ``` 129 | 130 | Now PHP-Watcher will restart on any changes to files in the directory (or 131 | subdirectories) with the extensions `.php`, `.yml`. 132 | 133 | ## Ignoring files 134 | 135 | By default, PHP-Watcher will only restart when a `.php` file changes. In 136 | some cases you may want to ignore some specific files, directories or file 137 | patterns, to prevent PHP-Watcher from prematurely restarting your application. 138 | 139 | This can be done via the command line: 140 | 141 | ```bash 142 | php-watcher server.php --ignore public/ --ignore tests/ 143 | ``` 144 | 145 | Or specific files can be ignored: 146 | 147 | ```bash 148 | php-watcher server.php --ignore src/config.php 149 | ``` 150 | 151 | Patterns can also be ignored (but be sure to quote the arguments): 152 | 153 | ```bash 154 | php-watcher server.php --ignore 'src/config/*.php' 155 | ``` 156 | 157 | Note that by default, PHP-Watcher ignores all *dot* and VCS files. 158 | 159 | ## Delaying restarting 160 | 161 | In some situations, you may want to wait until a number of files have changed 162 | . The timeout before checking for new file changes is 1 second. If you're 163 | uploading a number of files and it's taking some number of seconds, this could cause your app to 164 | restart multiple times unnecessarily. 165 | 166 | To add an extra throttle, or delay restarting, use the `--delay` option: 167 | 168 | ```bash 169 | php-watcher server.php --delay 10 170 | ``` 171 | 172 | For more precision, use a float: 173 | 174 | ```bash 175 | php-watcher server.php --delay 2.5 176 | ``` 177 | 178 | ## Default executable 179 | 180 | By default, PHP-Watcher uses `php` bin executable to run your scripts. If you 181 | want to provide your own executable use `--exec` option or `executable` param in config file. This is particularly useful if you're working with 182 | several PHP versions. 183 | 184 | ```yml 185 | executable: php 186 | ``` 187 | 188 | or using CLI: 189 | 190 | ```bash 191 | php-watcher server.php --exec php7 192 | ``` 193 | 194 | ### Running non-php scripts 195 | 196 | PHP-Watcher can also be used to execute and monitor other non-php programs. For example, you can use PHP-Watcher to listen to `*.js` files and use `node` executable to run them: 197 | 198 | ```bash 199 | php-watcher server.js --exec node --watch app --ext=js 200 | ``` 201 | 202 | The command above uses NodeJS to start `server.js` and then listens to changes in `app` directory. 203 | 204 | ## Gracefully reloading down your script 205 | 206 | It is possible to have PHP-watcher send any signal that you specify to your 207 | application. 208 | 209 | ```bash 210 | php-watcher --signal SIGTERM server.php 211 | ``` 212 | 213 | Your application can handle the signal as follows: 214 | 215 | ```php 216 | declare(ticks = 1); 217 | 218 | pcntl_signal(SIGTERM, 'terminationHandler'); 219 | 220 | function terminationHandler() 221 | { 222 | // ... 223 | } 224 | ``` 225 | 226 | By default PHP-watcher sends `SIGINT` signal. 227 | 228 | ## Automatic restart 229 | 230 | PHP-watcher was originally written to restart long-running processes such as web servers, but 231 | it also supports apps that cleanly exit. If your script exits cleanly, the watcher will continue 232 | to monitor the directory (or directories) and restart the script if there are any changes. If the 233 | script crashes PHP-watcher will notify you about that. 234 | 235 | ![app exit](images/exit.svg) 236 | 237 | ## Spinner 238 | 239 | By default the watcher outputs a nice spinner which indicates that the process is running 240 | and watching your files. But if your system doesn't support ansi coded the watcher 241 | will try to detect it and disable the spinner. Or you can always disable the spinner 242 | manually with option '--no-spinner': 243 | 244 | ```bash 245 | php-watcher server.php --no-spinner 246 | ``` 247 | 248 | # License 249 | 250 | MIT [http://rem.mit-license.org](http://rem.mit-license.org) 251 | 252 | ## How can I thank you? 253 | 254 | Why not star this GitHub repo? I'd love the attention! 255 | Or, you can donate to my project on PayPal: 256 | 257 | [![Support me with some coffee](https://img.shields.io/badge/donate-paypal-orange.svg)](https://www.paypal.me/seregazhuk) 258 | 259 | Thanks! 260 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seregazhuk/php-watcher", 3 | "type": "library", 4 | "description": "Automatically restart PHP application once the source code changes", 5 | "license": "MIT", 6 | "keywords": [ 7 | "php", 8 | "asynchronous", 9 | "console", 10 | "cli", 11 | "scripts", 12 | "watcher", 13 | "clock", 14 | "shell", 15 | "bash", 16 | "php-watcher" 17 | ], 18 | "authors": [ 19 | { 20 | "name": "Sergey Zhuk", 21 | "email": "seregazhuk88@gmail.com", 22 | "homepage": "http://sergeyzhuk.me", 23 | "role": "Developer" 24 | } 25 | ], 26 | "require": { 27 | "php": "^7.4|^8.0", 28 | "ext-json": "*", 29 | "ext-pcntl": "*", 30 | "yosymfony/resource-watcher": "^2.0", 31 | "symfony/console": "^4.3 || ^5.0", 32 | "react/event-loop": "^1.1", 33 | "symfony/yaml": "^4.3 || ^5.0", 34 | "react/child-process": "^0.6.1", 35 | "react/stream": "^1.0.0", 36 | "symfony/finder": "^4.3 || ^5.0", 37 | "alecrabbit/php-cli-snake": "^0.5" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "seregazhuk\\PhpWatcher\\": "src" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "seregazhuk\\PhpWatcher\\Tests\\": "tests" 47 | } 48 | }, 49 | "bin": [ 50 | "php-watcher" 51 | ], 52 | "require-dev": { 53 | "symfony/process": "^4.3", 54 | "phpunit/phpunit": "^8.0", 55 | "clue/block-react": "^1.3", 56 | "vimeo/psalm": "^3.8" 57 | }, 58 | 59 | "scripts": { 60 | "ci:php:psalm": "vendor/bin/psalm --show-info=false", 61 | "ci:tests": "vendor/bin/phpunit tests" 62 | }, 63 | "minimum-stability": "alpha" 64 | } 65 | -------------------------------------------------------------------------------- /images/demo.cast: -------------------------------------------------------------------------------- 1 | {"version": 2, "width": 90, "height": 25, "timestamp": 1570564118, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}} 2 | [0.885946, "o", "\u001b[33m[PHP-Watcher] 0.3.0\u001b[39m\r\n"] 3 | [0.886033, "o", "\u001b[33m[PHP-Watcher] watching: /Users/Develop/php-watcher\u001b[39m\r\n"] 4 | [0.886982, "o", "\u001b[32m[PHP-Watcher] starting `php server.php`\u001b[39m\r\n"] 5 | [0.887584, "o", "\u001b[?25l"] 6 | [0.887615, "o", "\u001b[38;5;196m⠏\u001b[0m \u001b[2D"] 7 | [0.927012, "o", "Listening on http://0.0.0.0:8080\r\n"] 8 | [0.988881, "o", "\u001b[38;5;196m⠛\u001b[0m \u001b[2D"] 9 | [1.092882, "o", "\u001b[38;5;202m⠹\u001b[0m \u001b[2D"] 10 | [1.196774, "o", "\u001b[38;5;202m⢸\u001b[0m \u001b[2D"] 11 | [1.298413, "o", "\u001b[38;5;208m⣰\u001b[0m \u001b[2D"] 12 | [1.400788, "o", "\u001b[38;5;208m⣤\u001b[0m \u001b[2D"] 13 | [1.501119, "o", "\u001b[38;5;214m⣆\u001b[0m \u001b[2D"] 14 | [1.605159, "o", "\u001b[38;5;214m⡇\u001b[0m \u001b[2D"] 15 | [1.70981, "o", "\u001b[38;5;220m⠏\u001b[0m \u001b[2D"] 16 | [1.812621, "o", "\u001b[38;5;220m⠛\u001b[0m \u001b[2D"] 17 | [1.917253, "o", "\u001b[38;5;226m⠹\u001b[0m \u001b[2D"] 18 | [2.021256, "o", "\u001b[38;5;226m⢸\u001b[0m \u001b[2D"] 19 | [2.12628, "o", "\u001b[38;5;190m⣰\u001b[0m \u001b[2D"] 20 | [2.226632, "o", "\u001b[38;5;190m⣤\u001b[0m \u001b[2D"] 21 | [2.328826, "o", "\u001b[38;5;154m⣆\u001b[0m \u001b[2D"] 22 | [2.433586, "o", "\u001b[38;5;154m⡇\u001b[0m \u001b[2D"] 23 | [2.534578, "o", "\u001b[38;5;118m⠏\u001b[0m \u001b[2D"] 24 | [2.637834, "o", "\u001b[38;5;118m⠛\u001b[0m \u001b[2D"] 25 | [2.739956, "o", "\u001b[38;5;82m⠹\u001b[0m \u001b[2D"] 26 | [2.841691, "o", "\u001b[38;5;82m⢸\u001b[0m \u001b[2D"] 27 | [2.946011, "o", "\u001b[38;5;46m⣰\u001b[0m \u001b[2D"] 28 | [3.049606, "o", "\u001b[38;5;46m⣤\u001b[0m \u001b[2D"] 29 | [3.152777, "o", "\u001b[38;5;47m⣆\u001b[0m \u001b[2D"] 30 | [3.255788, "o", "\u001b[38;5;47m⡇\u001b[0m \u001b[2D"] 31 | [3.356533, "o", "\u001b[38;5;48m⠏\u001b[0m \u001b[2D"] 32 | [3.460869, "o", "\u001b[38;5;48m⠛\u001b[0m \u001b[2D"] 33 | [3.565117, "o", "\u001b[38;5;49m⠹\u001b[0m \u001b[2D"] 34 | [3.668998, "o", "\u001b[38;5;49m⢸\u001b[0m \u001b[2D"] 35 | [3.772996, "o", "\u001b[38;5;50m⣰\u001b[0m \u001b[2D"] 36 | [3.876553, "o", "\u001b[38;5;50m⣤\u001b[0m \u001b[2D"] 37 | [3.976715, "o", "\u001b[38;5;51m⣆\u001b[0m \u001b[2D"] 38 | [4.078195, "o", "\u001b[38;5;51m⡇\u001b[0m \u001b[2D"] 39 | [4.180959, "o", "\u001b[38;5;45m⠏\u001b[0m \u001b[2D"] 40 | [4.284916, "o", "\u001b[38;5;45m⠛\u001b[0m \u001b[2D"] 41 | [4.38879, "o", "\u001b[38;5;39m⠹\u001b[0m \u001b[2D"] 42 | [4.493568, "o", "\u001b[38;5;39m⢸\u001b[0m \u001b[2D"] 43 | [4.59729, "o", "\u001b[38;5;33m⣰\u001b[0m \u001b[2D"] 44 | [4.70079, "o", "\u001b[38;5;33m⣤\u001b[0m \u001b[2D"] 45 | [4.804513, "o", "\u001b[38;5;27m⣆\u001b[0m \u001b[2D"] 46 | [4.90919, "o", "\u001b[38;5;27m⡇\u001b[0m \u001b[2D"] 47 | [4.965796, "o", " \u001b[2D"] 48 | [4.96596, "o", "\r\n\u001b[32m[PHP-Watcher] restarting due to changes...\u001b[39m\r\n"] 49 | [4.966163, "o", "\u001b[32m[PHP-Watcher] starting `php server.php`\u001b[39m\r\n"] 50 | [4.975091, "o", "\u001b[38;5;197m⢸\u001b[0m \u001b[2D"] 51 | [5.075366, "o", "\u001b[38;5;196m⣰\u001b[0m \u001b[2D"] 52 | [5.177859, "o", "\u001b[38;5;196m⣤\u001b[0m \u001b[2D"] 53 | [5.258737, "o", "Listening on http://0.0.0.0:8080\r\n"] 54 | [5.281585, "o", "\u001b[38;5;202m⣆\u001b[0m \u001b[2D"] 55 | [5.386626, "o", "\u001b[38;5;202m⡇\u001b[0m \u001b[2D"] 56 | [5.490073, "o", "\u001b[38;5;208m⠏\u001b[0m \u001b[2D"] 57 | [5.590363, "o", "\u001b[38;5;208m⠛\u001b[0m \u001b[2D"] 58 | [5.694731, "o", "\u001b[38;5;214m⠹\u001b[0m \u001b[2D"] 59 | [5.796149, "o", "\u001b[38;5;214m⢸\u001b[0m \u001b[2D"] 60 | [5.900526, "o", "\u001b[38;5;220m⣰\u001b[0m \u001b[2D"] 61 | [6.005266, "o", "\u001b[38;5;220m⣤\u001b[0m \u001b[2D"] 62 | [6.110118, "o", "\u001b[38;5;226m⣆\u001b[0m \u001b[2D"] 63 | [6.2831, "o", "\u001b[38;5;226m⡇\u001b[0m \u001b[2D"] 64 | [6.316951, "o", "\u001b[38;5;190m⠏\u001b[0m \u001b[2D"] 65 | [6.421874, "o", "\u001b[38;5;190m⠛\u001b[0m \u001b[2D"] 66 | [6.525341, "o", "\u001b[38;5;154m⠹\u001b[0m \u001b[2D"] 67 | [6.635849, "o", "\u001b[38;5;154m⢸\u001b[0m \u001b[2D"] 68 | [6.733629, "o", "\u001b[38;5;118m⣰\u001b[0m \u001b[2D"] 69 | [6.838338, "o", "\u001b[38;5;118m⣤\u001b[0m \u001b[2D"] 70 | [6.941806, "o", "\u001b[38;5;82m⣆\u001b[0m \u001b[2D"] 71 | [7.046271, "o", "\u001b[38;5;82m⡇\u001b[0m \u001b[2D"] 72 | [7.90498, "o", "\u001b[38;5;46m⠏\u001b[0m \u001b[2D"] 73 | [7.254863, "o", "\u001b[38;5;46m⠛\u001b[0m \u001b[2D"] 74 | [7.355085, "o", "\u001b[38;5;47m⠹\u001b[0m \u001b[2D"] 75 | -------------------------------------------------------------------------------- /images/demo.svg: -------------------------------------------------------------------------------- 1 | [PHP-Watcher]0.3.0[PHP-Watcher]watching:/Users/Develop/php-watcher[PHP-Watcher]starting`phpserver.php`Listeningonhttp://0.0.0.0:8080[PHP-Watcher]restartingduetochanges... -------------------------------------------------------------------------------- /images/exit.svg: -------------------------------------------------------------------------------- 1 | [PHP-Watcher]0.4.3[PHP-Watcher]watching:/Users/serega/Develop/my/php-watcher[PHP-Watcher]starting`phpserver.php`Listeningonhttp://0.0.0.0:8080[PHP-Watcher]appcrashed-waitingforfilechangesbeforestarting...[PHP-Watcher]restartingduetochanges...[PHP-Watcher]cleanexit-waitingforchangesbeforerestart -------------------------------------------------------------------------------- /images/watcher.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seregazhuk/php-watcher/765fefd2496b99904d9fc5e748556ce4641456cf/images/watcher.gif -------------------------------------------------------------------------------- /php-watcher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 3 | add($command); 16 | 17 | $application->setDefaultCommand($command->getName(), true); 18 | $application->run(); 19 | -------------------------------------------------------------------------------- /php-watcher.yml.dist: -------------------------------------------------------------------------------- 1 | watch: 2 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | src 19 | 20 | src/Filesystem/watcher.php 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/Config/Builder.php: -------------------------------------------------------------------------------- 1 | findConfigFile() : $path; 18 | $values = empty($pathToConfig) ? [] : $this->valuesFromConfigFile($pathToConfig); 19 | 20 | return Config::fromArray($values); 21 | } 22 | 23 | public function fromCommandLineArgs(InputExtractor $input): Config 24 | { 25 | $values = $this->valuesFromCommandLineArgs($input); 26 | return Config::fromArray($values); 27 | } 28 | 29 | private function valuesFromConfigFile(string $configFilePath): array 30 | { 31 | $contents = file_get_contents($configFilePath); 32 | if ($contents === false) { 33 | throw InvalidConfigFileContents::invalidContents($configFilePath); 34 | } 35 | 36 | $values = Yaml::parse($contents); 37 | if ($values === null) { 38 | throw InvalidConfigFileContents::invalidContents($configFilePath); 39 | } 40 | 41 | return $values; 42 | } 43 | 44 | public function findConfigFile(): ?string 45 | { 46 | $configDirectory = getcwd(); 47 | foreach (self::SUPPORTED_CONFIG_NAMES as $configName) { 48 | $configFullPath = "{$configDirectory}/{$configName}"; 49 | 50 | if (file_exists($configFullPath)) { 51 | return $configFullPath; 52 | } 53 | } 54 | 55 | return null; 56 | } 57 | 58 | private function valuesFromCommandLineArgs(InputExtractor $input): array 59 | { 60 | return [ 61 | 'script' => $input->getStringArgument('script'), 62 | 'executable' => $input->getStringOption('exec'), 63 | 'watch' => $input->getArrayOption('watch'), 64 | 'extensions' => $input->getArrayOption('ext'), 65 | 'ignore' => $input->getArrayOption('ignore'), 66 | 'signal' => $input->getStringOption('signal'), 67 | 'delay' => $input->getFloatOption('delay'), 68 | 'arguments' => $input->getArrayOption('arguments'), 69 | 'no-spinner' => $input->getBooleanOption('no-spinner'), 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Config/Config.php: -------------------------------------------------------------------------------- 1 | script = $script; 30 | $this->phpExecutable = $phpExecutable ?: PHP_BINARY; 31 | $this->signalToReload = $signalToReload ?: self::DEFAULT_SIGNAL_TO_RELOAD; 32 | $this->delay = $delay ?: self::DEFAULT_DELAY_IN_SECONDS; 33 | $this->arguments = $arguments; 34 | $this->spinnerDisabled = $spinnerDisabled; 35 | $this->watchList = $watchList; 36 | } 37 | 38 | public static function fromArray(array $values): self 39 | { 40 | return new self( 41 | $values['script'] ?? null, 42 | $values['executable'] ?? null, 43 | isset($values['signal']) ? constant($values['signal']) : null, 44 | $values['delay'] ?? null, 45 | $values['arguments'] ?? [], 46 | $values['no-spinner'] ?? false, 47 | new WatchList( 48 | $values['watch'] ?? [], 49 | $values['extensions'] ?? [], 50 | $values['ignore'] ?? [] 51 | ) 52 | ); 53 | } 54 | 55 | public function watchList(): WatchList 56 | { 57 | return $this->watchList; 58 | } 59 | 60 | public function command(): string 61 | { 62 | $commandline = implode(' ', [$this->phpExecutable, $this->script, implode(' ', $this->arguments)]); 63 | if ('\\' !== DIRECTORY_SEPARATOR) { 64 | // exec is mandatory to deal with sending a signal to the process 65 | $commandline = 'exec '.$commandline; 66 | } 67 | 68 | return $commandline; 69 | } 70 | 71 | public function delay(): float 72 | { 73 | return $this->delay; 74 | } 75 | 76 | public function signalToReload(): int 77 | { 78 | return $this->signalToReload; 79 | } 80 | 81 | public function spinnerDisabled(): bool 82 | { 83 | return $this->spinnerDisabled; 84 | } 85 | 86 | public function merge(self $another): self 87 | { 88 | return new self( 89 | empty($this->script) && $another->script ? $another->script : $this->script, 90 | $this->phpExecutable === PHP_BINARY && $another->phpExecutable ? $another->phpExecutable: $this->phpExecutable, 91 | $this->signalToReload === self::DEFAULT_SIGNAL_TO_RELOAD && $another->signalToReload ? $another->signalToReload : $this->signalToReload, 92 | $this->delay === self::DEFAULT_DELAY_IN_SECONDS && $another->delay ? $another->delay: $this->delay, 93 | empty($this->arguments) && !empty($another->arguments) ? $another->arguments : $this->arguments, 94 | $another->spinnerDisabled ?: $this->spinnerDisabled, 95 | $another->watchList->merge($this->watchList) 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Config/InputExtractor.php: -------------------------------------------------------------------------------- 1 | input = $input; 14 | } 15 | 16 | public function getStringArgument(string $key, string $default = null): ?string 17 | { 18 | $argument = $this->input->getArgument($key); 19 | 20 | return $this->stringValueOrDefault($argument, $default); 21 | } 22 | 23 | public function getStringOption(string $key, string $default = null): ?string 24 | { 25 | $option = $this->input->getOption($key); 26 | 27 | return $this->stringValueOrDefault($option, $default); 28 | } 29 | 30 | private function stringValueOrDefault($value, string $default = null): ?string 31 | { 32 | if ($value === null) { 33 | return $default; 34 | } 35 | 36 | if (is_array($value) && isset($value[0])) { 37 | return (string)$value[0]; 38 | } 39 | 40 | return (string)$value; 41 | } 42 | 43 | public function getArrayOption(string $key): array 44 | { 45 | $option = $this->input->getOption($key); 46 | 47 | if (is_string($option) && !empty($option)) { 48 | return explode(',', $option); 49 | } 50 | 51 | if (!is_array($option)) { 52 | return []; 53 | } 54 | 55 | return empty($option) ? [] : $option; 56 | } 57 | 58 | public function getFloatOption(string $key): float 59 | { 60 | return (float)$this->input->getOption($key); 61 | } 62 | 63 | public function getBooleanOption(string $key): bool 64 | { 65 | return (bool)$this->input->getOption($key); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Config/InvalidConfigFileContents.php: -------------------------------------------------------------------------------- 1 | paths = empty($paths) ? [getcwd()] : $paths; 27 | $this->extensions = empty($extensions) ? self::DEFAULT_EXTENSIONS : $extensions; 28 | $this->ignore = $ignore; 29 | } 30 | 31 | public function fileExtensions(): array 32 | { 33 | return array_map( 34 | function ($extension) { 35 | return '*.'.$extension; 36 | }, 37 | $this->extensions 38 | ); 39 | } 40 | 41 | /** 42 | * @return string[] 43 | */ 44 | public function paths(): array 45 | { 46 | return $this->paths; 47 | } 48 | 49 | public function isWatchingForEverything(): bool 50 | { 51 | return empty($this->paths); 52 | } 53 | 54 | public function hasIgnoring(): bool 55 | { 56 | return !empty($this->ignore); 57 | } 58 | 59 | public function ignore(): array 60 | { 61 | return $this->ignore; 62 | } 63 | 64 | public static function fromJson(string $json): self 65 | { 66 | $values = json_decode($json, true); 67 | return new self($values['paths'], $values['extensions'], $values['ignore']); 68 | } 69 | 70 | public function merge(self $another): self 71 | { 72 | return new self( 73 | $this->hasDefaultPath() && !empty($another->paths) ? $another->paths : $this->paths, 74 | $this->hasDefaultExtensions() && !empty($another->extensions) ? $another->extensions : $this->extensions, 75 | empty($this->ignore) && !empty($another->ignore) ? $another->ignore : $this->ignore 76 | ); 77 | } 78 | 79 | private function hasDefaultPath(): bool 80 | { 81 | return $this->paths === [getcwd()]; 82 | } 83 | 84 | private function hasDefaultExtensions(): bool 85 | { 86 | return $this->extensions === self::DEFAULT_EXTENSIONS; 87 | } 88 | 89 | public function toJson(): string 90 | { 91 | return json_encode([ 92 | 'paths' => $this->paths, 93 | 'ignore' => $this->ignore, 94 | 'extensions' => $this->extensions 95 | ]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/ConsoleApplication.php: -------------------------------------------------------------------------------- 1 | add(new WatcherCommand()); 16 | } 17 | 18 | public function getLongVersion(): string 19 | { 20 | return parent::getLongVersion() . ' by Sergey Zhuk'; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Filesystem/ChangesListener.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 19 | } 20 | 21 | public function start(WatchList $watchList): void 22 | { 23 | $watcher = ResourceWatcherBuilder::create($watchList); 24 | 25 | $this->loop->addPeriodicTimer( 26 | self::INTERVAL, 27 | function () use ($watcher) { 28 | if ($watcher->findChanges()->hasChanges()) { 29 | $this->emit('change'); 30 | } 31 | } 32 | ); 33 | } 34 | 35 | public function onChange(callable $callback): void 36 | { 37 | $this->on('change', $callback); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Filesystem/ResourceWatcherBased/ResourceWatcherBuilder.php: -------------------------------------------------------------------------------- 1 | paths() 39 | ); 40 | } 41 | 42 | private static function makeDefaultFinder(WatchList $watchList): Finder 43 | { 44 | return (new Finder()) 45 | ->ignoreDotFiles(false) 46 | ->ignoreVCS(false) 47 | ->name($watchList->fileExtensions()) 48 | ->files() 49 | ->notPath($watchList->ignore()); 50 | } 51 | 52 | private static function appendFinderWithPath(Finder $finder, WatchPath $watchPath): void 53 | { 54 | $finder->in($watchPath->path()); 55 | 56 | if ($watchPath->isFileOrPattern()) { 57 | $finder->name($watchPath->fileName()); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Filesystem/WatchPath.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 12 | } 13 | 14 | public function isFileOrPattern(): bool 15 | { 16 | return !$this->isDirectory() || !file_exists($this->pattern); 17 | } 18 | 19 | private function directoryPart(): string 20 | { 21 | return pathinfo($this->pattern, PATHINFO_DIRNAME); 22 | } 23 | 24 | public function fileName(): string 25 | { 26 | return pathinfo($this->pattern, PATHINFO_BASENAME); 27 | } 28 | 29 | private function isDirectory(): bool 30 | { 31 | return is_dir($this->pattern); 32 | } 33 | 34 | public function path(): string 35 | { 36 | return $this->isDirectory() ? $this->pattern : $this->directoryPart(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ProcessRunner.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 21 | $this->screen = $screen; 22 | $this->process = new ReactPHPProcess($command); 23 | } 24 | 25 | public function start(): void 26 | { 27 | $this->screen->start($this->process->getCommand()); 28 | $this->screen->showSpinner($this->loop); 29 | 30 | $this->process->start($this->loop); 31 | $this->subscribeToProcessOutput(); 32 | } 33 | 34 | public function stop(int $signal): void 35 | { 36 | $this->process->terminate($signal); 37 | $this->process->removeAllListeners(); 38 | } 39 | 40 | public function restart(float $delayToRestart): void 41 | { 42 | $this->screen->restarting($this->process->getCommand()); 43 | $this->loop->addTimer($delayToRestart, [$this, 'start']); 44 | } 45 | 46 | private function subscribeToProcessOutput(): void 47 | { 48 | if ($this->process->stdout === null || $this->process->stderr === null) { 49 | throw new RuntimeException('Cannot open I/O for a process'); 50 | } 51 | 52 | $this->process->stdout->on('data', [$this->screen, 'plainOutput']); 53 | $this->process->stderr->on('data', [$this->screen, 'plainOutput']); 54 | $this->process->on('exit', [$this->screen, 'processExit']); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Screen/Screen.php: -------------------------------------------------------------------------------- 1 | output = $output; 20 | $this->spinner = $spinner; 21 | } 22 | 23 | public function showOptions(WatchList $watchList): void 24 | { 25 | $this->title(); 26 | $this->showWatchList($watchList); 27 | } 28 | 29 | private function showWatchList(WatchList $watchList): void 30 | { 31 | $watching = $watchList->isWatchingForEverything() ? '*.*' : implode(', ', $watchList->paths()); 32 | $this->comment('watching: ' . $watching); 33 | 34 | if ($watchList->hasIgnoring()) { 35 | $this->comment('ignoring: ' . implode(', ', $watchList->ignore())); 36 | } 37 | } 38 | 39 | private function title(): void 40 | { 41 | $this->comment(ConsoleApplication::VERSION); 42 | } 43 | 44 | private function comment(string $text): void 45 | { 46 | $text = sprintf('%s', $this->message($text)); 47 | $this->output->writeln($text); 48 | } 49 | 50 | private function info(string $text): void 51 | { 52 | $text = sprintf('%s', $this->message($text)); 53 | $this->output->writeln($text); 54 | } 55 | 56 | private function warning(string $text): void 57 | { 58 | $text = sprintf('%s', $this->message($text)); 59 | $this->output->writeln($text); 60 | } 61 | 62 | public function start(string $command): void 63 | { 64 | $command = str_replace(['exec', PHP_BINARY], ['', 'php'], $command); 65 | $this->info(sprintf('starting `%s`', trim($command))); 66 | } 67 | 68 | public function restarting(string $command = null): void 69 | { 70 | $this->spinner->erase(); 71 | $this->output->writeln(''); 72 | $this->info('restarting due to changes...'); 73 | 74 | if ($command !== null) { 75 | $this->start($command); 76 | } 77 | } 78 | 79 | public function processExit(int $exitCode): void 80 | { 81 | if ($exitCode === 0) { 82 | $this->info('clean exit - waiting for changes before restart'); 83 | } else { 84 | $this->warning('app crashed - waiting for file changes before starting...'); 85 | } 86 | } 87 | 88 | public function plainOutput(string $data): void 89 | { 90 | $this->output->write($data); 91 | } 92 | 93 | public function showSpinner(LoopInterface $loop): void 94 | { 95 | $this->spinner->begin(); 96 | $loop->addPeriodicTimer($this->spinner->interval(), function () { 97 | $this->spinner->spin(); 98 | }); 99 | } 100 | 101 | private function message(string $text): string 102 | { 103 | return sprintf('[%s] %s', ConsoleApplication::NAME, $text); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Screen/SpinnerFactory.php: -------------------------------------------------------------------------------- 1 | getFormatter()->isDecorated(); 14 | if (!$hasColorSupport || $spinnerDisabled) { 15 | return new VoidSpinner(); 16 | } 17 | 18 | return new Spinner(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Screen/VoidSpinner.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 20 | $this->filesystemListener = $filesystemListener; 21 | } 22 | 23 | public function startWatching( 24 | ProcessRunner $processRunner, 25 | WatchList $watchList, 26 | int $signal, 27 | float $delayToRestart 28 | ): void { 29 | $processRunner->start(); 30 | 31 | $this->filesystemListener->start($watchList); 32 | $this->filesystemListener->onChange( 33 | static function () use ($processRunner, $signal, $delayToRestart) { 34 | $processRunner->stop($signal); 35 | $processRunner->restart($delayToRestart); 36 | } 37 | ); 38 | 39 | $this->loop->run(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/WatcherCommand.php: -------------------------------------------------------------------------------- 1 | setName('watch') 27 | ->setDescription('Restart PHP application once the source code changes.') 28 | ->addArgument('script', InputArgument::REQUIRED, 'Script to run') 29 | ->addOption( 30 | 'watch', 31 | '-w', 32 | InputOption::VALUE_IS_ARRAY + InputOption::VALUE_OPTIONAL, 33 | 'Paths to watch' 34 | ) 35 | ->addOption('ext', '-e', InputOption::VALUE_OPTIONAL, 'Extensions to watch', '') 36 | ->addOption( 37 | 'ignore', 38 | '-i', 39 | InputOption::VALUE_IS_ARRAY + InputOption::VALUE_OPTIONAL, 40 | 'Paths to ignore', 41 | [] 42 | ) 43 | ->addOption('exec', null, InputOption::VALUE_OPTIONAL, 'PHP executable') 44 | ->addOption('delay', null, InputOption::VALUE_OPTIONAL, 'Delaying restart') 45 | ->addOption('signal', null, InputOption::VALUE_OPTIONAL, 'Signal to reload the app') 46 | ->addOption( 47 | 'arguments', 48 | null, 49 | InputOption::VALUE_IS_ARRAY + InputOption::VALUE_OPTIONAL, 50 | 'Arguments for the script', 51 | [] 52 | ) 53 | ->addOption('config', null, InputOption::VALUE_OPTIONAL, 'Path to config file') 54 | ->addOption('no-spinner', null, InputOption::VALUE_NONE, 'Remove spinner from output'); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output) 58 | { 59 | $loop = Factory::create(); 60 | $config = $this->buildConfig(new InputExtractor($input)); 61 | $spinner = SpinnerFactory::create($output, $config->spinnerDisabled()); 62 | 63 | $this->addTerminationListeners($loop, $spinner); 64 | 65 | $screen = new Screen(new SymfonyStyle($input, $output), $spinner); 66 | $filesystem = new ChangesListener($loop); 67 | 68 | $screen->showOptions($config->watchList()); 69 | $processRunner = new ProcessRunner($loop, $screen, $config->command()); 70 | 71 | $watcher = new Watcher($loop, $filesystem); 72 | $watcher->startWatching( 73 | $processRunner, 74 | $config->watchList(), 75 | $config->signalToReload(), 76 | $config->delay() 77 | ); 78 | 79 | return 0; 80 | } 81 | 82 | /** 83 | * When terminating the watcher we need to manually restore the cursor after the spinner. 84 | */ 85 | private function addTerminationListeners(LoopInterface $loop, SpinnerInterface $spinner): void 86 | { 87 | $func = static function (int $signal) use ($spinner): void { 88 | $spinner->end(); 89 | exit($signal); 90 | }; 91 | 92 | $loop->addSignal(SIGINT, $func); 93 | $loop->addSignal(SIGTERM, $func); 94 | } 95 | 96 | private function buildConfig(InputExtractor $input): Config 97 | { 98 | $builder = new Builder(); 99 | $fromFile = $builder->fromConfigFile($input->getStringOption('config')); 100 | $fromCommandLineArgs = $builder->fromCommandLineArgs($input); 101 | 102 | return $fromFile->merge($fromCommandLineArgs); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/Feature/ChangesListenerTest.php: -------------------------------------------------------------------------------- 1 | start(new WatchList([Filesystem::fixturesDir()])); 24 | 25 | $loop->addTimer(1, [Filesystem::class, 'createHelloWorldPHPFile']); 26 | $eventWasEmitted = false; 27 | $listener->on('change', static function () use (&$eventWasEmitted) { 28 | $eventWasEmitted = true; 29 | }); 30 | sleep(4, $loop); // to be sure changes have been detected 31 | 32 | $this->assertTrue($eventWasEmitted, '"change" event should be emitted'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Feature/ConfigTest.php: -------------------------------------------------------------------------------- 1 | ['directory-to-watch']]); 14 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 15 | 16 | $this->watch($fileToWatch, ['--watch', 'tests', '--config', $configFile]); 17 | $this->wait(); 18 | 19 | $this->assertOutputDoesntContain('directory-to-watch'); 20 | } 21 | 22 | /** @test */ 23 | public function it_can_use_config_path_from_command_line_arg(): void 24 | { 25 | $configFile = Filesystem::createConfigFile(['watch' => ['directory-to-watch']]); 26 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 27 | 28 | $this->watch($fileToWatch, ['--config', $configFile]); 29 | $this->wait(); 30 | 31 | $this->assertOutputContains('watching: directory-to-watch'); 32 | } 33 | 34 | /** @test */ 35 | public function it_uses_values_from_config(): void 36 | { 37 | $configFile = Filesystem::createConfigFile(['watch' => ['first', 'second']]); 38 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 39 | 40 | $this->watch($fileToWatch, ['--config', $configFile]); 41 | $this->wait(); 42 | 43 | $this->assertOutputContains('watching: first, second'); 44 | } 45 | 46 | /** @test */ 47 | public function command_line_options_override_values_from_config(): void 48 | { 49 | $configFile = Filesystem::createConfigFile(['watch' => ['directory-to-watch']]); 50 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 51 | 52 | $this->watch($fileToWatch, ['--watch', $configFile]); 53 | $this->wait(); 54 | 55 | $this->assertOutputContains("watching: $configFile"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Feature/Helper/Filesystem.php: -------------------------------------------------------------------------------- 1 | start(); 14 | 15 | return $process; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Feature/Helper/WatcherTestCase.php: -------------------------------------------------------------------------------- 1 | watcherRunner = (new WatcherRunner())->run($scriptToRun, $options); 27 | } 28 | 29 | public function assertOutputContains(string $string): void 30 | { 31 | $output = $this->watcherRunner->getOutput(); 32 | $this->assertStringContainsString($string, $output); 33 | } 34 | 35 | public function assertOutputDoesntContain(string $string): void 36 | { 37 | $output = $this->watcherRunner->getOutput(); 38 | $this->assertStringNotContainsString($string, $output); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Feature/Helper/WithFilesystem.php: -------------------------------------------------------------------------------- 1 | watch($fileToWatch, ['--watch' , __DIR__, '--ignore', basename($fileToWatch),]); 15 | $this->wait(); 16 | 17 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 19 | $this->assertOutputDoesntContain('restarting due to changes...'); 20 | } 21 | 22 | /** @test */ 23 | public function it_doesnt_reload_when_ignored_directories_change(): void 24 | { 25 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 26 | $this->watch($fileToWatch, ['--watch' , __DIR__, '--ignore', Filesystem::fixturesDir()]); 27 | $this->wait(); 28 | 29 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 31 | $this->assertOutputDoesntContain('restarting due to changes...'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Feature/RunScriptTest.php: -------------------------------------------------------------------------------- 1 | watch($scriptToRun); 15 | 16 | $this->wait(); 17 | 18 | $this->assertOutputContains("starting `php $scriptToRun`"); 19 | $this->assertOutputContains('Hello, world'); 20 | } 21 | 22 | /** @test */ 23 | public function it_outputs_the_script_stderr(): void 24 | { 25 | $scriptToRun = Filesystem::createStdErrorPHPFile(); 26 | $this->watch($scriptToRun); 27 | 28 | $this->wait(); 29 | 30 | $this->assertOutputContains('Some error'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Feature/ScriptExitTest.php: -------------------------------------------------------------------------------- 1 | watch($fileToWatch, ['--watch', $fileToWatch]); 15 | $this->wait(); 16 | 17 | $this->assertOutputContains('clean exit - waiting for changes before restart'); 18 | } 19 | 20 | /** @test */ 21 | public function it_detects_when_script_crashes(): void 22 | { 23 | $fileToWatch = Filesystem::createPHPFileThatCrashes(); 24 | $this->watch($fileToWatch, ['--watch', $fileToWatch]); 25 | $this->wait(); 26 | 27 | $this->assertOutputContains('app crashed - waiting for file changes before starting'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Feature/SignalTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('SIGTERM is not defined'); 15 | } 16 | 17 | $scriptToRun = Filesystem::createHelloWorldPHPFileWithSignalsHandling(); 18 | $this->watch($scriptToRun, ['--signal', 'SIGTERM', '--watch', Filesystem::fixturesDir()]); 19 | $this->wait(); 20 | 21 | Filesystem::createHelloWorldPHPFile(); 22 | $this->wait(); 23 | 24 | $this->assertOutputContains(SIGTERM . ' signal was received'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Feature/WatchDirectoriesTest.php: -------------------------------------------------------------------------------- 1 | watch($fileToWatch, ['--watch', Filesystem::fixturesDir()]); 15 | $this->wait(); 16 | 17 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 19 | $this->assertOutputContains('Something changed'); 20 | } 21 | 22 | /** @test */ 23 | public function it_reloads_by_changes_in_a_watched_dir(): void 24 | { 25 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 26 | $this->watch($fileToWatch, ['--watch', Filesystem::fixturesDir()]); 27 | $this->wait(); 28 | 29 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 31 | 32 | $this->assertOutputContains('restarting due to changes...'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Feature/WatchFilesTest.php: -------------------------------------------------------------------------------- 1 | watch($fileToWatch, ['--watch', $fileToWatch]); 15 | $this->wait(); 16 | 17 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 19 | $this->assertOutputContains('Something changed'); 20 | } 21 | 22 | /** @test */ 23 | public function it_reloads_by_changes_in_a_watched_file(): void 24 | { 25 | $fileToWatch = Filesystem::createHelloWorldPHPFile(); 26 | $this->watch($fileToWatch, ['--watch', $fileToWatch]); 27 | $this->wait(); 28 | 29 | Filesystem::changeFileContentsWith($fileToWatch, 'wait(); 31 | $this->assertOutputContains('restarting due to changes...'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Unit/SpinnerFactoryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(VoidSpinner::class, $spinner); 20 | } 21 | 22 | /** @test */ 23 | public function it_should_return_void_spinner_if_ansi_output_is_not_supported(): void 24 | { 25 | $output = new NullOutput(); 26 | $spinner = SpinnerFactory::create($output, $spinnerDisabled = false); 27 | $this->assertInstanceOf(VoidSpinner::class, $spinner); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Unit/WatchPathTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('/root/test', $path->path()); 15 | } 16 | 17 | /** @test */ 18 | public function it_provides_a_directory_path_for_a_directory(): void 19 | { 20 | $path = new WatchPath('/root/test'); 21 | $this->assertEquals('/root', $path->path()); 22 | } 23 | 24 | /** @test */ 25 | public function it_provides_a_filename_for_a_file(): void 26 | { 27 | $path = new WatchPath('/root/test.txt'); 28 | $this->assertEquals('test.txt', $path->fileName()); 29 | } 30 | 31 | /** @test */ 32 | public function it_provides_a_pattern_path_for_a_pattern(): void 33 | { 34 | $path = new WatchPath('/root/test.*'); 35 | $this->assertEquals('test.*', $path->fileName()); 36 | } 37 | 38 | /** @test */ 39 | public function it_can_detect_pattern_or_a_file(): void 40 | { 41 | $path = new WatchPath('/root/test.*'); 42 | $this->assertTrue($path->isFileOrPattern()); 43 | 44 | $path = new WatchPath('/root/test.txt'); 45 | $this->assertTrue($path->isFileOrPattern()); 46 | 47 | $path = new WatchPath('/root/*.txt'); 48 | $this->assertTrue($path->isFileOrPattern()); 49 | 50 | $path = new WatchPath(__DIR__); 51 | $this->assertFalse($path->isFileOrPattern()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/fixtures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seregazhuk/php-watcher/765fefd2496b99904d9fc5e748556ce4641456cf/tests/fixtures/.gitkeep --------------------------------------------------------------------------------