├── .styleci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── config.php ├── logo.jpg └── src ├── CommandLineInterface └── CommandLineInterface.php ├── Console └── TestWatcherCommand.php ├── Contracts ├── AnnotatedTestsFinderContract.php ├── CommandLineInterfaceContract.php ├── PHPUnitRunnerContract.php └── TestFileContract.php ├── Facades └── LaravelTestWatcher.php ├── Factories └── LaravelTestWatcherFactory.php ├── Finders └── TestsAnnotatedWithWatchFinder.php ├── LaravelTestWatcher.php ├── LaravelTestWatcherServiceProvider.php ├── PHPUnitRunner.php └── TestFiles ├── FilesToTestRepository.php ├── InvalidTestFile.php ├── TestFile.php └── TestFilesCollection.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - single_class_element_per_statement 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-test-watcher` will be documented in this file 4 | 5 | ## 1.0.0 - 2019-04-01 6 | 7 | - Initial release 8 | 9 | ## 1.0.1 - 2019-04-01 10 | 11 | Updated PHP version dependency to 7.2 in composer.json file 12 | 13 | ## 1.0.2 - 2019-04-01 14 | 15 | Removed version number from CLI UI header 16 | 17 | ## 1.0.3 - 2019-04-01 18 | 19 | Bug fixes 20 | 21 | ## 1.0.4 - 2019-04-02 22 | 23 | Fixed issue with missing error output on Windows 24 | 25 | ## 1.0.5 - 2019-04-04 26 | 27 | Fixed issue where Laravel Test Watcher's environment would override PHPUnits environment. 28 | 29 | ## 1.0.6 - 2019-04-05 30 | 31 | Fixed bug where tests with long failure output would get cut off 32 | 33 | ## 1.0.7 - 2019-04-05 34 | 35 | Fixed bug where CLI did not update when all watch annotations has been removed 36 | 37 | ## 1.0.8 - 2019-04-11 38 | 39 | Added composer.lock to repository for Snyk vulnerability testing 40 | 41 | ## 1.0.9 - 2019-08-31 42 | 43 | Added Laravel 6.0 Compatibility 44 | 45 | ## 1.0.10 - 2019-12-12 46 | 47 | Added Laravel 6.* Compatibility 48 | 49 | ## 1.0.11 - 2019-12-12 50 | 51 | Bumped up other dependencies for compatibility with Laravel 6.7.0 52 | 53 | ## 1.0.12 - 2020-03-03 54 | 55 | Added Laravel 7 Compatibility 56 | 57 | ## 1.0.13 - 2020-03-30 58 | 59 | Fixed an issue where DotEnv would give an error on Laravel 7, since the way to initialize DotEnv v4 has been changed from DotEnv v3. 60 | The package now supports both versions. 61 | 62 | ## 1.0.14 - 2020-09-29 63 | 64 | Added Laravel 8 Compatibility 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Thomas Noergaard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](logo.jpg) 2 | # Run specific tests methods when your test or source code changes 3 | 4 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/wackystudio/laravel-test-watcher.svg?style=flat-square)](https://packagist.org/packages/wackystudio/laravel-test-watcher) 5 | [![Build Status](https://travis-ci.org/WackyStudio/laravel-test-watcher.svg?branch=master&style=flat-square)](https://travis-ci.org/WackyStudio/laravel-test-watcher.svg?branch=master) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/wackystudio/laravel-test-watcher.svg?style=flat-square)](https://packagist.org/packages/wackystudio/laravel-test-watcher) 7 | 8 | When looking at our testing workflow, we realized how often we were triggering our tests, especially single test cases or groups of test cases. 9 | Using an IDE like PHPStorm this is done quickly with a keyboard shortcut, but in other IDEs, or editors, this is not always as easy. 10 | Therefore we have built Laravel Test Watcher. 11 | 12 | Instead of running your entire test suite or having to group your tests, Laravel Test Watcher can watch 13 | test cases you annotate with a `@watch` annotation. 14 | 15 | You start the watcher through the `tests:watch` artisan command. 16 | As soon as you save a test file with a `@watch` annotation on a test case, 17 | Laravel Test Watcher automatically notice that you have added the annotation 18 | and run the test case for every change in your source code. 19 | 20 | When you are finished testing the test case, you can tell Laravel Test Watcher 21 | to stop watching the test case by removing the `@watch` annotation again; it is as easy as that. 22 | 23 | No need to jump between your IDE/editor and the terminal, adding or removing `@watch` annotations in your code is enough, 24 | and Laravel Test Watcher takes care of the rest. 25 | 26 | ## Installation 27 | 28 | You can install the package via composer: 29 | 30 | ```bash 31 | composer require wackystudio/laravel-test-watcher 32 | ``` 33 | 34 | ## Usage 35 | To watch a test in a test class, use the @watch annotation like this: 36 | ``` php 37 | /** 38 | * @test 39 | * @watch 40 | */ 41 | public function it_runs_annotated_tests_in_this_test_file() 42 | { 43 | //... 44 | } 45 | ``` 46 | If you are not using a `@test` annotation but are adding test to your test methods name, you can watch the test case like this: 47 | ``` php 48 | /** 49 | * @watch 50 | */ 51 | public function test_it_runs_annotated_tests_every_time_source_code_changes() 52 | { 53 | //... 54 | } 55 | ``` 56 | To watch tests and source file for changes, run the test watcher through Laravel Artisan like this: 57 | ```bash 58 | php artisan tests:watch 59 | ``` 60 | 61 | **NOTICE:** 62 | For database testing we recommend that you create a `.env.testing` environment file with details for a dedicated testing database. 63 | If you don't do this, Laravel Test Watcher will test against the database given in the `.env` file, which we do not recommend. 64 | 65 | ### Configuration 66 | By default Laravel Test Watcher watches all files in the `app` `routes` and `tests` folders, 67 | meaning that any changes to a file in these directories, makes Laravel Test Watcher run all the watched test cases. 68 | 69 | If you want to configure which directories Laravel Test Watcher should watch, you can do this by publishing the configuration file 70 | through the `vendor:publish` artisan command like this: 71 | ```bash 72 | php artisan vendor:publish 73 | ``` 74 | publish the configuration file for Laravel Test Watcher only or select the config tag to publish configuration files, for all packages in your Laravel Application. 75 | 76 | ### Limitations 77 | Even though Laravel Test Watcher can watch as many tests as you like, 78 | it is not the intention that you should use it on every single test case in your test suite but instead, use it on the tests for the current feature you are implementing. 79 | 80 | Since it is not possible to tell PHPUnit to run multiple single test cases so all test cases can be tested in a single PHPUnit session, each test case is running in its own PHPUnit session, which makes the execution of the tests a bit slower. 81 | 82 | If you need to run all your tests, we recommend you run a good old: 83 | ```bash 84 | ./vendor/bin/phpunit 85 | ``` 86 | This will run through all of your tests in your test suite much faster. 87 | 88 | When starting Laravel Test Watcher through the artisan command, it bootstraps the entire Laravel application and loads the environment variables defined in the `.env` file. 89 | This gives us some issues since PHPUnit does not override the loaded environment variables when running tests which make each test run with the environment variables already loaded, 90 | instead of the testing environment variables it should be using. 91 | To mitigate this, Laravel Test Watcher requires a `.env.testing` file where all your environment variables for your testing setup is defined. 92 | This is then used to override the environment variables when Laravel Test Watcher has been instantiated. 93 | Unfortunately, this means that you cannot use the environment variables you have defined in your `phpunit.xml` file. 94 | 95 | ### Testing 96 | ``` bash 97 | composer test 98 | ``` 99 | 100 | ### Changelog 101 | 102 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 103 | 104 | ## Contributing 105 | 106 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 107 | 108 | ### Security 109 | 110 | If you discover any security related issues, please email tgn@wackystudio.com instead of using the issue tracker. 111 | 112 | ## Credits 113 | 114 | - [Thomas Nørgaard](https://github.com/thomasnoergaard) 115 | - [All Contributors](../../contributors) 116 | 117 | ## License 118 | 119 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 120 | 121 | ## Laravel Package Boilerplate 122 | 123 | This package was generated using the [Laravel Package Boilerplate](https://laravelpackageboilerplate.com). -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wackystudio/laravel-test-watcher", 3 | "description": "A test file watcher for Laravel that automatically runs specific test cases based on a @watch annotation", 4 | "keywords": [ 5 | "wackystudio", 6 | "laravel-test-watcher" 7 | ], 8 | "homepage": "https://github.com/wackystudio/laravel-test-watcher", 9 | "license": "MIT", 10 | "type": "library", 11 | "authors": [ 12 | { 13 | "name": "Thomas Noergaard", 14 | "email": "tgn@wackystudio.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.2|^7.3|^7.4", 20 | "illuminate/support": "5.8.*|6.*|7.*|8.*", 21 | "league/climate": "^3.5", 22 | "react/event-loop": "^1.1", 23 | "spatie/laravel-collection-macros": "^6.0|^7.0.3", 24 | "symfony/console": "^4.2|5.*", 25 | "symfony/finder": "^4.2|5.*", 26 | "symfony/process": "^4.2|5.*", 27 | "wyrihaximus/react-child-process-promise": "^2.0", 28 | "yosymfony/resource-watcher": "^2.0" 29 | }, 30 | "require-dev": { 31 | "orchestra/testbench": "3.*|4.*", 32 | "phpunit/phpunit": "^7.0|^8.0|^9.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "WackyStudio\\LaravelTestWatcher\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "WackyStudio\\LaravelTestWatcher\\Tests\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "vendor/bin/phpunit", 46 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 47 | 48 | }, 49 | "config": { 50 | "sort-packages": true 51 | }, 52 | "extra": { 53 | "laravel": { 54 | "providers": [ 55 | "WackyStudio\\LaravelTestWatcher\\LaravelTestWatcherServiceProvider" 56 | ], 57 | "aliases": { 58 | "LaravelTestWatcher": "WackyStudio\\LaravelTestWatcher\\Facades\\LaravelTestWatcher" 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'app', 19 | 'routes', 20 | 'tests', 21 | ], 22 | ]; 23 | -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WackyStudio/laravel-test-watcher/6dc91e4d447f4e230d9ceba5930cb8a9a6ed43ae/logo.jpg -------------------------------------------------------------------------------- /src/CommandLineInterface/CommandLineInterface.php: -------------------------------------------------------------------------------- 1 | climate = $climate; 25 | $this->filesToTest = $filesToTest; 26 | } 27 | 28 | public function render() 29 | { 30 | $this->climate->clear(); 31 | $this->headerContent(); 32 | $this->emptyLine(); 33 | $this->testsContent(); 34 | $this->failedTestsContent(); 35 | } 36 | 37 | public function emptyLine() 38 | { 39 | $this->climate->out("\n"); 40 | } 41 | 42 | public function headerContent() 43 | { 44 | $this->emptyLine(); 45 | $this->emptyLine(); 46 | $this->climate->out(implode(PHP_EOL, [ 47 | 'Laravel Test Watcher', 48 | 'By Wacky Studio', 49 | '', 50 | '____________________', 51 | ])); 52 | } 53 | 54 | public function testsContent() 55 | { 56 | if ($this->filesToTest->getFilesToTest() 57 | ->count() == 0) { 58 | $this->climate->out('No test cases to watch'); 59 | 60 | return; 61 | } 62 | $rowsNeeded = $this->filesToTest->getFilesToTest() 63 | ->map(function (TestFile $file) { 64 | return count($file->getMethodsToWatch()); 65 | }) 66 | ->max(); 67 | 68 | $tests = $this->filesToTest 69 | ->getFilesToTest() 70 | ->map(function (TestFile $file) use ($rowsNeeded) { 71 | $passed = collect($file->getPassedTests()); 72 | $failed = collect($file->getFailedTests()); 73 | 74 | return collect([ 75 | "{$file->getNamespace()}\\{$file->getClassName()}", 76 | "\n", 77 | ])->merge(collect($file->getMethodsToWatch())->map(function ($item) use ($passed, $failed) { 78 | if ($passed->contains($item)) { 79 | return "{$item}"; 80 | } elseif ($failed->contains(function ($failed) use ($item) { 81 | return $failed['method'] === $item; 82 | })) { 83 | return "{$item}"; 84 | } else { 85 | return $item; 86 | } 87 | }))->pad($rowsNeeded + 2, ''); 88 | }); 89 | $this->climate->columns($tests->transpose()->toArray()); 90 | } 91 | 92 | public function failedTestsContent() 93 | { 94 | $failedOutput = $this->filesToTest->getFilesToTest() 95 | ->flatMap(function (TestFile $file) { 96 | return collect($file->getFailedTests())->map(function ($item) { 97 | return $item['content']; 98 | }); 99 | }); 100 | 101 | if ($failedOutput->count() > 0) { 102 | $this->emptyLine(); 103 | $this->emptyLine(); 104 | 105 | $this->climate->out("{$failedOutput->count()} test(s) are failing:"); 106 | 107 | $failedOutput->each(function ($content) { 108 | $this->emptyLine(); 109 | 110 | $collection = $this->removeLineBreaksAndEmptyLines($content); 111 | $collection = $this->removeUnnecessaryPHPUnitOutput($collection); 112 | 113 | $collection->each(function ($item, $key) { 114 | if ($key === 0) { 115 | $this->climate->backgroundWhite()->black()->bold(' '.str_replace('1) ', '', $item).' '); 116 | } elseif ($key === 1) { 117 | $this->climate->backgroundRed() 118 | ->white(' '.$item.' '); 119 | } else { 120 | $this->climate->bold($item); 121 | } 122 | }); 123 | 124 | $this->emptyLine(); 125 | }); 126 | } 127 | } 128 | 129 | /** 130 | * @param $content 131 | * 132 | * @return \Illuminate\Support\Collection 133 | */ 134 | private function removeLineBreaksAndEmptyLines($content) 135 | { 136 | return collect(explode("\n", $content))->filter(function ($item) { 137 | return trim($item) !== ''; 138 | })->values(); 139 | } 140 | 141 | /** 142 | * @param Collection $collection 143 | * 144 | * @return Collection 145 | */ 146 | private function removeUnnecessaryPHPUnitOutput(Collection $collection) 147 | { 148 | $collection = $collection->slice(4) 149 | ->values(); 150 | $collection->pop(); 151 | $collection->pop(); 152 | 153 | return $collection; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Console/TestWatcherCommand.php: -------------------------------------------------------------------------------- 1 | info('Starting test watcher...'); 17 | $this->changeEnvironment(); 18 | LaravelTestWatcher::watch(); 19 | } 20 | 21 | private function changeEnvironment() 22 | { 23 | if (file_exists(base_path('.env.testing'))) { 24 | try { 25 | $dotenv = Dotenv::create(base_path(), '.env.testing'); 26 | $dotenv->overload(); 27 | } catch (\TypeError $error) { 28 | $dotenv = Dotenv::createMutable(base_path(), '.env.testing'); 29 | $dotenv->load(); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Contracts/AnnotatedTestsFinderContract.php: -------------------------------------------------------------------------------- 1 | make(); 23 | } 24 | 25 | /** 26 | * Creates a new LaravelTestWatcher instance. 27 | * 28 | * @return LaravelTestWatcher 29 | */ 30 | public function make() 31 | { 32 | $loop = app(LoopInterface::class); 33 | $testFiles = $this->makeFinderForTestFiles(); 34 | $directoriesWatcher = $this->makeDirectoriesWatcher(); 35 | $annotatedTestFinder = app(AnnotatedTestsFinderContract::class); 36 | 37 | return new LaravelTestWatcher($loop, $testFiles, $directoriesWatcher, $annotatedTestFinder); 38 | } 39 | 40 | /** 41 | * @return Finder 42 | */ 43 | public function makeFinderForTestFiles() 44 | { 45 | $testFiles = $this->makeFinder([base_path('tests')]) 46 | ->name('*.php'); 47 | 48 | return $testFiles; 49 | } 50 | 51 | /** 52 | * @param Finder $finder 53 | * 54 | * @return ResourceWatcher 55 | */ 56 | public function makeDirectoriesWatcher() 57 | { 58 | $finder = $this->makeFinder($this->getDirectoriesToWatch()); 59 | $watcher = new ResourceWatcher(new ResourceCacheMemory(), $finder, new Crc32ContentHash()); 60 | 61 | return $watcher; 62 | } 63 | 64 | /** 65 | * @return Finder 66 | */ 67 | public function makeFinder(array $directories) 68 | { 69 | $finder = new Finder(); 70 | $finder->files() 71 | ->in($directories); 72 | 73 | return $finder; 74 | } 75 | 76 | /** 77 | * Maps directories given in config file to base path. 78 | * 79 | * @return array 80 | */ 81 | public function getDirectoriesToWatch() 82 | { 83 | $directories = Collection::make(Config::get('laravel-test-watcher.watch_directories')); 84 | 85 | return $directories->map(function ($directory) { 86 | return base_path($directory); 87 | })->toArray(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Finders/TestsAnnotatedWithWatchFinder.php: -------------------------------------------------------------------------------- 1 | convertContentsToTokenCollection($fileContents); 30 | $tokens = $this->filterWhiteSpaceAndMapTokensToNames($tokens); 31 | $namespace = $this->findNameSpace($tokens); 32 | $className = $this->findClassName($tokens); 33 | $testMethods = $this->findTestsAnnotatedWithWatch($tokens); 34 | 35 | return new TestFile($filePath, $className, $testMethods, $namespace); 36 | } 37 | 38 | /** 39 | * @param string $fileContents 40 | * 41 | * @return Collection 42 | */ 43 | public function convertContentsToTokenCollection(string $fileContents) 44 | { 45 | return new Collection(token_get_all($fileContents)); 46 | } 47 | 48 | /** 49 | * @param Collection $collection 50 | * 51 | * @return Collection 52 | */ 53 | public function filterWhiteSpaceAndMapTokensToNames(Collection $collection) 54 | { 55 | return $collection->filter(function ($item) { 56 | if (is_array($item)) { 57 | return trim($item[1]) !== ''; 58 | } else { 59 | return false; 60 | } 61 | })->map(function ($item) { 62 | return $item[1]; 63 | })->values(); 64 | } 65 | 66 | public function findNameSpace(Collection $tokens) 67 | { 68 | $namespace = []; 69 | foreach ($tokens as $key => $token) { 70 | if (strpos($token, 'namespace') !== false) { 71 | $currentKey = 1; 72 | while ($tokens[$key + $currentKey] !== 'use' && $tokens[$key + $currentKey] !== 'class') { 73 | array_push($namespace, $tokens[$key + $currentKey]); 74 | $currentKey++; 75 | } 76 | 77 | return implode('', $namespace); 78 | } 79 | } 80 | 81 | return ''; 82 | } 83 | 84 | /** 85 | * @param Collection $tokens 86 | * 87 | * @return string 88 | */ 89 | public function findClassName(Collection $tokens) 90 | { 91 | foreach ($tokens as $key => $token) { 92 | if (strpos($token, 'class') !== false) { 93 | return $tokens[$key + 1]; 94 | } 95 | } 96 | 97 | return ''; 98 | } 99 | 100 | /** 101 | * @param Collection $tokens 102 | * 103 | * @return array 104 | */ 105 | public function findTestsAnnotatedWithWatch(Collection $tokens) 106 | { 107 | $testMethods = []; 108 | foreach ($tokens as $key => $token) { 109 | if (strpos($token, '@watch') !== false) { 110 | if ($tokens[$key + 2] !== 'function') { 111 | continue; 112 | } 113 | $testMethod = $tokens[$key + 3]; 114 | array_push($testMethods, $testMethod); 115 | } 116 | } 117 | 118 | return $testMethods; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/LaravelTestWatcher.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 48 | $this->testFiles = $testFiles; 49 | $this->directoriesWatcher = $directoriesWatcher; 50 | $this->filesToTest = app(FilesToTestRepository::class); 51 | $this->cli = app(CommandLineInterfaceContract::class); 52 | $this->phpunitRunner = app(PHPUnitRunnerContract::class); 53 | } 54 | 55 | public function prepare() 56 | { 57 | $files = []; 58 | foreach ($this->testFiles as $file) { 59 | array_push($files, $file->getRealPath()); 60 | } 61 | $this->filesToTest->update($files); 62 | $this->cli->render(); 63 | $this->phpunitRunner->run(); 64 | } 65 | 66 | public function watch() 67 | { 68 | $this->prepare(); 69 | $this->loop->addPeriodicTimer(1 / 4, function () { 70 | if ($this->phpunitRunner->isRunning()) { 71 | return; 72 | } 73 | $result = $this->directoriesWatcher->findChanges(); 74 | if ($result->hasChanges()) { 75 | if (count($result->getDeletedFiles()) > 0) { 76 | $this->filesToTest->update($result->getDeletedFiles()); 77 | $this->directoriesWatcher->rebuild(); 78 | } 79 | if (count($result->getNewFiles()) > 0) { 80 | $this->filesToTest->update($result->getNewFiles()); 81 | $this->directoriesWatcher->rebuild(); 82 | } 83 | if (count($result->getUpdatedFiles()) > 0) { 84 | $this->filesToTest->update($result->getUpdatedFiles()); 85 | $this->phpunitRunner->run(); 86 | $this->cli->render(); 87 | } 88 | } 89 | }); 90 | $this->loop->run(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/LaravelTestWatcherServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 23 | $this->publishes([ 24 | __DIR__.'/../config/config.php' => config_path('laravel-test-watcher.php'), 25 | ], 'config'); 26 | $this->commands([ 27 | TestWatcherCommand::class, 28 | ]); 29 | } 30 | } 31 | 32 | public function register() 33 | { 34 | $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'laravel-test-watcher'); 35 | 36 | $this->app->singleton('laravel-test-watcher', function () { 37 | return ( new LaravelTestWatcherFactory )->make(); 38 | }); 39 | 40 | $this->app->singleton(FilesToTestRepository::class, function () { 41 | return new FilesToTestRepository(app(AnnotatedTestsFinderContract::class)); 42 | }); 43 | 44 | $this->app->singleton(CommandLineInterfaceContract::class, function () { 45 | return new CommandLineInterface(app(FilesToTestRepository::class), new CLImate); 46 | }); 47 | 48 | $this->app->singleton(PHPUnitRunnerContract::class, function () { 49 | return new PHPUnitRunner(app(FilesToTestRepository::class), app(CommandLineInterfaceContract::class)); 50 | }); 51 | 52 | $this->app->bind(AnnotatedTestsFinderContract::class, function () { 53 | return new TestsAnnotatedWithWatchFinder; 54 | }); 55 | 56 | $this->app->bind(LoopInterface::class, function () { 57 | return Factory::create(); 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/PHPUnitRunner.php: -------------------------------------------------------------------------------- 1 | filesToTestRepository = $filesToTestRepository; 36 | $this->cli = $cli; 37 | } 38 | 39 | public function run() 40 | { 41 | if ($this->filesToTestRepository->getFilesToTest()->count() === 0) { 42 | return; 43 | } 44 | 45 | $this->isRunningTests = true; 46 | 47 | $this->filesToTestRepository->getFilesToTest()->each(function (TestFile $test) { 48 | $test->resetStatuses(); 49 | 50 | foreach ($test->getMethodsToWatch() as $key=>$method) { 51 | $process = new Process([base_path().'/vendor/bin/phpunit', '--filter', $method, $test->getFilePath()], base_path()); 52 | 53 | try { 54 | $process->mustRun(); 55 | $test->addPassedTest($method); 56 | } catch (ProcessFailedException $exception) { 57 | $test->addFailedTest($method, $exception->getProcess()->getOutput()); 58 | } 59 | 60 | $this->cli->render(); 61 | 62 | if ($key == array_keys($test->getMethodsToWatch())[count($test->getMethodsToWatch()) - 1]) { 63 | $this->isRunningTests = false; 64 | } 65 | } 66 | }); 67 | } 68 | 69 | public function isRunning() 70 | { 71 | return $this->isRunningTests; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/TestFiles/FilesToTestRepository.php: -------------------------------------------------------------------------------- 1 | testFinder = $testFinder; 22 | $this->collection = new TestFilesCollection; 23 | } 24 | 25 | /** 26 | * @param $files 27 | */ 28 | public function update(array $files) 29 | { 30 | $this->oldCollection = clone $this->collection; 31 | 32 | foreach ($files as $file) { 33 | $testFile = $this->testFinder->findAnnotatedTests($file); 34 | if ($testFile->hasAnyTests()) { 35 | $this->collection->updateOrAdd($testFile); 36 | } else { 37 | $this->collection->removeIfExist($testFile); 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * @return Collection 44 | */ 45 | public function getFilesToTest() 46 | { 47 | return $this->collection->getCollection(); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function getChanges() 54 | { 55 | $changes = $this->collection->compareToOldCollection($this->oldCollection); 56 | 57 | return (new Collection($changes))->map(function ($item) { 58 | return (new Collection($item))->map(function ($file) { 59 | if ($file instanceof TestFileContract) { 60 | return [ 61 | 'file' => $file->getNamespace().'\\'.$file->getClassName(), 62 | 'methods' => $file->getMethodsToWatch(), 63 | ]; 64 | } 65 | 66 | if (is_array($file) && isset($file['new']) && isset($file['old'])) { 67 | return [ 68 | 'file' => $file['new']->getNamespace().'\\'.$file['new']->getClassName(), 69 | 'methods' => $file['new']->getMethodsToWatch(), 70 | 'droppedMethods' => array_values(array_diff($file['old']->getMethodsToWatch(), $file['new']->getMethodsToWatch())), 71 | ]; 72 | } 73 | 74 | return $file; 75 | }); 76 | })->toArray(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/TestFiles/InvalidTestFile.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 14 | } 15 | 16 | /** 17 | * @return bool 18 | */ 19 | public function hasAnyTests() 20 | { 21 | return false; 22 | } 23 | 24 | /** 25 | * @return string 26 | */ 27 | public function getClassName() 28 | { 29 | return 'invalid'; 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | public function getMethodsToWatch() 36 | { 37 | return []; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getFilePath() 44 | { 45 | return $this->filePath; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getNamespace() 52 | { 53 | return ''; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/TestFiles/TestFile.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 20 | $this->className = $className; 21 | $this->testMethods = $testMethods; 22 | $this->namespace = $namespace; 23 | } 24 | 25 | /** 26 | * @return string 27 | */ 28 | public function getClassName() 29 | { 30 | return $this->className; 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function getMethodsToWatch() 37 | { 38 | return $this->testMethods; 39 | } 40 | 41 | /** 42 | * @return bool 43 | */ 44 | public function hasAnyTests() 45 | { 46 | return count($this->getMethodsToWatch()) > 0; 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | public function getFilePath() 53 | { 54 | return $this->filePath; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function getNamespace() 61 | { 62 | return $this->namespace; 63 | } 64 | 65 | /** 66 | * @param $passedTest 67 | */ 68 | public function addPassedTest($passedTest) 69 | { 70 | array_push($this->passedTestMethods, $passedTest); 71 | } 72 | 73 | /** 74 | * @return array 75 | */ 76 | public function getPassedTests() 77 | { 78 | return $this->passedTestMethods; 79 | } 80 | 81 | /** 82 | * @param $method 83 | * @param $content 84 | */ 85 | public function addFailedTest($method, $content) 86 | { 87 | array_push($this->failedTestMethods, [ 88 | 'method' => $method, 89 | 'content' => $content, 90 | ]); 91 | } 92 | 93 | /** 94 | * @return array 95 | */ 96 | public function getFailedTests() 97 | { 98 | return $this->failedTestMethods; 99 | } 100 | 101 | public function resetStatuses() 102 | { 103 | $this->passedTestMethods = []; 104 | $this->failedTestMethods = []; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/TestFiles/TestFilesCollection.php: -------------------------------------------------------------------------------- 1 | collection = new Collection([]); 19 | } 20 | } 21 | 22 | /** 23 | * @param TestFileContract $file 24 | * 25 | * @return $this 26 | */ 27 | public function add(TestFileContract $file) 28 | { 29 | $this->collection->add($file); 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * @param TestFileContract $file 36 | * 37 | * @return $this 38 | */ 39 | public function update(TestFileContract $file) 40 | { 41 | $this->collection = $this->collection->filter(function (TestFileContract $item) use ($file) { 42 | return $item->getFilePath() !== $file->getFilePath(); 43 | })->add($file)->values(); 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @param TestFileContract $file 50 | */ 51 | public function updateOrAdd(TestFileContract $file) 52 | { 53 | $this->update($file); 54 | } 55 | 56 | /** 57 | * @param TestFileContract $file 58 | */ 59 | public function removeIfExist(TestFileContract $file) 60 | { 61 | $this->collection = $this->collection->filter(function (TestFileContract $item) use ($file) { 62 | return $item->getFilePath() !== $file->getFilePath(); 63 | })->values(); 64 | } 65 | 66 | public function has(TestFileContract $file) 67 | { 68 | return $this->collection->contains(function (TestFileContract $item) use ($file) { 69 | return $item->getFilePath() === $file->getFilePath(); 70 | }); 71 | } 72 | 73 | /** 74 | * @param string $filePath 75 | * 76 | * @return TestFileContract|null 77 | */ 78 | public function getByFilePath(string $filePath) 79 | { 80 | return $this->collection->filter(function (TestFileContract $item) use ($filePath) { 81 | return $item->getFilePath() === $filePath; 82 | })->first(); 83 | } 84 | 85 | /** 86 | * @return Collection 87 | */ 88 | public function getCollection() 89 | { 90 | return $this->collection; 91 | } 92 | 93 | /** 94 | * @param TestFilesCollection $oldTestFilesCollection 95 | * 96 | * @return array 97 | */ 98 | public function compareToOldCollection(self $oldTestFilesCollection) 99 | { 100 | $oldCollection = $oldTestFilesCollection->getCollection(); 101 | $new = $this->collection->filter(function (TestFileContract $file) use ($oldCollection) { 102 | return ! $oldCollection->contains(function (TestFileContract $item) use ($file) { 103 | return $file->getFilePath() === $item->getFilePath(); 104 | }); 105 | })->values(); 106 | 107 | $updated = $this->collection->map(function (TestFileContract $file) use ($oldCollection) { 108 | $oldMatch = $oldCollection->filter(function (TestFileContract $item) use ($file) { 109 | return $file->getFilePath() === $item->getFilePath(); 110 | })->first(); 111 | 112 | if ($oldMatch !== null && count(array_diff($oldMatch->getMethodsToWatch(), $file->getMethodsToWatch()))) { 113 | return ['old' => $oldMatch, 'new' => $file]; 114 | } 115 | })->filter(function ($item) { 116 | return $item !== null; 117 | })->values(); 118 | 119 | $removed = $oldCollection->filter(function (TestFileContract $file) { 120 | return ! $this->collection->contains(function (TestFileContract $item) use ($file) { 121 | return $file->getFilePath() === $item->getFilePath(); 122 | }); 123 | })->values(); 124 | 125 | return [ 126 | 'added' => $new->toArray(), 127 | 'updated' => $updated->toArray(), 128 | 'removed' => $removed->toArray(), 129 | ]; 130 | } 131 | 132 | public function __clone() 133 | { 134 | return new self(new Collection($this->collection->toArray())); 135 | } 136 | } 137 | --------------------------------------------------------------------------------