├── .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 | 
2 | # Run specific tests methods when your test or source code changes
3 |
4 | [](https://packagist.org/packages/wackystudio/laravel-test-watcher)
5 | [](https://travis-ci.org/WackyStudio/laravel-test-watcher.svg?branch=master)
6 | [](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 |
--------------------------------------------------------------------------------