From d7757ff2766d8eb91aa040ffc495fefbd1154e22 Mon Sep 17 00:00:00 2001 From: Youri van Weegberg Date: Sun, 31 Mar 2024 14:34:36 +0300 Subject: [PATCH] Initial commit --- .editorconfig | 18 + .gitattributes | 11 + .github/dependabot.yml | 19 + .github/release-drafter.yml | 43 + .github/workflows/coverage.yml | 50 + .github/workflows/dependabot-merge.yml | 26 + .github/workflows/pint-formatting.yml | 31 + .github/workflows/release-drafter.yml | 24 + .github/workflows/release.yml | 44 + .github/workflows/run-tests.yml | 52 + .gitignore | 7 + CHANGELOG.md | 12 + LICENSE.md | 9 + README.md | 160 + art/logo.svg | 13208 +++++++++++++++++++++++ composer.json | 56 + phpstan.neon | 4 + phpunit.xml | 35 + pint.json | 6 + src/Console/AddCommand.php | 90 + src/Console/BaseCommand.php | 167 + src/Console/InstallCommand.php | 98 + src/Console/ListCommand.php | 61 + src/Console/PublishCommand.php | 108 + src/Console/RenameCommand.php | 58 + src/Contracts/Serviceable.php | 47 + src/Exceptions/SailorException.php | 10 + src/Facades/Sailor.php | 37 + src/SailorManager.php | 279 + src/SailorService.php | 215 + src/SailorServiceProvider.php | 72 + testbench.yaml | 3 + tests/Feature/AddCommandTest.php | 70 + tests/Feature/CommandTestCase.php | 159 + tests/Feature/InstallCommandTest.php | 88 + tests/Feature/ListCommandTest.php | 21 + tests/Feature/PublishCommandTest.php | 27 + tests/Feature/RenameCommandTest.php | 90 + tests/Unit/AddCommandTest.php | 155 + tests/Unit/BaseCommandTest.php | 158 + tests/Unit/InstallCommandTest.php | 184 + tests/Unit/ListCommandTest.php | 67 + tests/Unit/PublishCommandTest.php | 125 + tests/Unit/RenameCommandTest.php | 109 + tests/Unit/SailorManagerTest.php | 327 + tests/Unit/SailorServiceTest.php | 44 + 46 files changed, 16684 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/dependabot-merge.yml create mode 100644 .github/workflows/pint-formatting.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 art/logo.svg create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 src/Console/AddCommand.php create mode 100644 src/Console/BaseCommand.php create mode 100644 src/Console/InstallCommand.php create mode 100644 src/Console/ListCommand.php create mode 100644 src/Console/PublishCommand.php create mode 100644 src/Console/RenameCommand.php create mode 100644 src/Contracts/Serviceable.php create mode 100644 src/Exceptions/SailorException.php create mode 100644 src/Facades/Sailor.php create mode 100644 src/SailorManager.php create mode 100644 src/SailorService.php create mode 100644 src/SailorServiceProvider.php create mode 100644 testbench.yaml create mode 100644 tests/Feature/AddCommandTest.php create mode 100644 tests/Feature/CommandTestCase.php create mode 100644 tests/Feature/InstallCommandTest.php create mode 100644 tests/Feature/ListCommandTest.php create mode 100644 tests/Feature/PublishCommandTest.php create mode 100644 tests/Feature/RenameCommandTest.php create mode 100644 tests/Unit/AddCommandTest.php create mode 100644 tests/Unit/BaseCommandTest.php create mode 100644 tests/Unit/InstallCommandTest.php create mode 100644 tests/Unit/ListCommandTest.php create mode 100644 tests/Unit/PublishCommandTest.php create mode 100644 tests/Unit/RenameCommandTest.php create mode 100644 tests/Unit/SailorManagerTest.php create mode 100644 tests/Unit/SailorServiceTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8e55f1f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto + +/.github export-ignore +/tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore +pint.json export-ignore +testbench.yaml export-ignore \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dccec25 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + reviewers: + - "yourivw" + labels: + - "changelog:maintenance" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "yourivw" + labels: + - "changelog:maintenance" \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..5b112ea --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,43 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: Added + labels: + - 'changelog:added' + - title: Changed + labels: + - 'changelog:changed' + - title: Deprecated + labels: + - 'changelog:deprecated' + - title: Removed + labels: + - 'changelog:removed' + - title: Fixed + labels: + - 'changelog:fixed' + - title: Security + labels: + - 'changelog:security' + - title: 'Maintenance & updates' + labels: + - 'changelog:maintenance' +change-template: '- $TITLE @$AUTHOR ([#$NUMBER](https://github.com/yourivw/sailor/pull/$NUMBER))' +version-resolver: + major: + labels: + - 'changelog:removed' + minor: + labels: + - 'changelog:added' + - 'changelog:deprecated' + patch: + labels: + - 'changelog:fixed' + - 'changelog:security' + - 'changelog:maintenance' + default: patch +template: | + $CHANGES +exclude-labels: + - 'changelog:ignore' \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..8a7d268 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,50 @@ +name: Analyse code coverage + +on: + push: + branches: + - main + paths: + - src/**/*.php + +permissions: + contents: write + +jobs: + coverage: + name: Analyse code coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + tools: composer:v2 + + - name: Run composer install + run: | + composer require "laravel/framework:11.*" --no-interaction --no-update + composer install --prefer-dist --no-interaction + + - name: Run PHPUnit tests with coverage + run: ./vendor/bin/phpunit --coverage-clover clover.xml + + - name: Generate test coverage badge + uses: timkrase/phpunit-coverage-badge@v1.2.1 + with: + coverage_badge_path: 'output/badge-coverage.svg' + push_badge: false + + - name: Git push badge to image-data branch + uses: peaceiris/actions-gh-pages@v3 + with: + publish_dir: ./output + publish_branch: gh-pages + github_token: ${{ secrets.GITHUB_TOKEN }} + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' \ No newline at end of file diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 0000000..8ca7201 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,26 @@ +name: Dependabot auto merge + +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + name: Dependabot auto merge + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Get Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.0.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Merge Dependabot PR's for patch and minor updates + if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pint-formatting.yml b/.github/workflows/pint-formatting.yml new file mode 100644 index 0000000..17f44fb --- /dev/null +++ b/.github/workflows/pint-formatting.yml @@ -0,0 +1,31 @@ +name: Pint formatting + +on: + pull_request: + paths: + - src/**/*.php + +permissions: + contents: write + +jobs: + pint: + name: Pint formatting + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run formatting + uses: aglipanci/laravel-pint-action@2.4 + with: + configPath: pint.json + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commit_message: 'Pint formatting 🍻' \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..c92e962 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,24 @@ +name: Draft release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: read + +jobs: + draft_release: + name: Draft release + if: github.actor != 'github-actions[bot]' && github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Draft release changes + uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b057764 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Publish release + +on: workflow_dispatch + +permissions: + contents: write + +jobs: + release: + name: Publish release + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Get draft release + uses: cardinalby/git-get-release-action@v1 + id: get_draft_release + env: + GITHUB_TOKEN: ${{ github.token }} + with: + latest: true + draft: true + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Update changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + release-notes: ${{ steps.get_draft_release.outputs.body }} + latest-version: ${{ steps.get_draft_release.outputs.name }} + + - name: Commit updated changelog + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Update CHANGELOG.md + file_pattern: CHANGELOG.md + + - name: Publish release + uses: eregon/publish-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + release_id: ${{ steps.get_draft_release.outputs.id }} \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..1ac993f --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,52 @@ +name: Run tests + +on: + push: + branches: + - main + - '[0-9]+.**' + paths: + - src/**/*.php + - composer.json + - .github/workflows/** + pull_request: + paths: + - src/**/*.php + - composer.json + - .github/workflows/** + +jobs: + tests: + name: Run tests on PHP ${{ matrix.php }} and Laravel ${{ matrix.laravel }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: [8.3, 8.2, 8.1] + laravel: [11.*, 10.*] + exclude: + - laravel: 11.* + php: 8.1 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Run composer install + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer install --prefer-dist --no-interaction + + - name: Run PHPUnit tests + run: ./vendor/bin/phpunit + + - name: Run static analysis + run: ./vendor/bin/phpstan --error-format=github \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87af57e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.vscode +/coverage +/vendor +/workbench +composer.lock +docker-compose.yml +.phpunit.result.cache \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c69fe4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased](https://github.com/yourivw/sailor/compare/v1.0.0...HEAD) + +## [v1.0.0](https://github.com/yourivw/sailor/releases/tag/v1.0.0) - 2024-04-01 + +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e1133de --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) Youri van Weegberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..20350eb --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +

+ +

+Testing status +Code coverage +Latest stable version +License +

+ +## Introduction + +This package is an extension to Laravel Sail, enabling the user to install additional services to the Sail installation. The idea behind this, is that this enables you to create an additional package which defines your services which can then be easily installed in your new project consistenly and quickly. The package also gives the options to overwrite which Sail packages are used as default, and how the Laravel service is named. + +My own services are available as well, and can be used by installing [yourivw/sailor-services](https://github.com/yourivw/sailor-services). + +## Installation + +Install this package as a dev dependency using composer: + +```composer require yourivw/sailor --dev``` + +## Defining Sailor services + +Services can be defines in two ways: fluently using ```SailorService::create()```, and by defining a new class implementing the Serviceable interface. After defining, register the service to the manager using ```Sailor::register()``` on the Sailor Facade. Alternatively, the SailorService class has a register function, so that the service can be fluently registered. + +In both examples, the callbacks can be used for further setup. For example, dynamically adding something to the docker-compose files through the ```$compose``` argument, using the ```$command``` argument to write to the output, copying extra files when publishing etc. + +Like in these examples, I would advise to check whether the app is running in the console, in order not to uselessly register these services unless your application is running in console. + +### Fluent example + +```php +if ($this->app->runningInConsole()) { + SailorService::create('example', __DIR__ . '/../stubs/example.stub') + ->useDefault() + ->withVolume() + ->withPublishable([__DIR__ . '/../example-files' => $this->app->basePath('docker/sailor/example-files')]) + ->callAfterAdding(function (Command $command, array &$compose) { + $compose['services']['example']['environment']['HELLO'] = 'WORLD'; + }) + ->callAfterPublishing(function (Command $command) { + $command->info('Successfully published example service files.'); + }) + ->register(); +} +``` + +### Interface example +```php +class ExampleService implements Serviceable +{ + public function name(): string + { + return 'example'; + } + + public function stubFilePath(): string + { + return __DIR__.'/../stubs/example.stub'; + } + + // Other interface functions similair to the fluent example. +} +``` +```php +if ($this->app->runningInConsole()) { + Sailor::register(new ExampleService()); +} +``` + +### Other configuration + +```php +// Set which Sail services are checked by default. +Sailor::setSailDefaultServices(['mysql', 'redis']); + +// Set the default name for the Laravel service. +Sailor::setDefaultServiceName('laravel-example.local'); +``` + +### Note on volumes + +Sailor will automatically add a volume when your service has defined it required a volume, in the same way Sail does this. However, these volumes are prefixed with 'sailor-' in the docker-compose file. Be sure to refer to these volumes correctly in your stub file. The volume is named after your service name, plus the sailor- prefix. See example: + +```php +SailorService::create('redisinsight', __DIR__ . '/../stubs/redisinsight.stub') + ->withVolume() + ->register(); +``` +```yml +services: + redisinsight: + image: '...' + volumes: + - 'sailor-redisinsight:/data' +... +volumes: + sailor-redisinsight: + driver: local +``` + +## Usage + +### Installation command + +The installation command can be used in a similar fashion to the default Sail installation command. Additionally, a check is performed whether a docker-compose file already exists, and if so, an error will be shown. To add a service, use the add command instead. Also, there is an option to directly rename the Laravel service. In the background, Sail's install command will run to handle the installation of the standard services, and this package will handle the custom services. See ```sailor:install --help``` for more information on usage. + +### Add command + +The add command can be used in a similar fashion to the default Sail add command. Additionally, a check is performed whether a docker-compose file already exists, and if not, an error will be shown. To create a new intallation, use the install command instead. In the background, Sail's add command will run to handle the standard services, and this package will handle the custom services. See ```sailor:add --help``` for more information on usage. + +### Rename command + +The rename command can be used to rename the Laravel service. It will find the service in the existing Docker file, and rename it. A line it added to, or edited in the .env file, specifying the new service name. Your Laravel instance will now be reachable on this URL. Ensure this URL points to the correct address for it to work, e.g. by adding it to your Windows hosts file. See ```sailor:rename --help``` for more information on usage. + +Renaming the installation will cause the Sail commands to not find the Laravel installation anymore. It's advised to use only the Sailor commands to add packages to prevent this. + +### Publish command + +To further ease your workflow when modifying services, your new service can also specify what it should publish, when this is required. An example: the PHP runtimes which Sail can publish for you, in order to customize these. It's not required to define this, and can be skipped all together. When running the publish command, specific services can be chosen using the ```--services``` option to limit which service files get publishes. See ```sailor:publish --help``` for more information on usage. + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information about recent changes. + +## Contributing + +Contributions are more than welcome. Please read the information on issues and PR's below. + +### Issues + +* Make sure the issue is reproduceable. +* Make sure the issue has not already been raised. +* Give as much information about the problem as possible. + +### Pull requests + +* Make sure additional features add to the functionality of this package. +* Ensure all tests pass, and if required, add tests. +* Do not make PR's adding pre-defined services to this package, these will be closed. This package is purely an interface between Sail and custom services. +* Upon submitting a PR, testing and formatting actions will run automatically. + +## Testing + +The package uses PHPUnit tests and PHPStan for static analysis. + +``` +vendor/bin/phpunit +vendor/bin/phpstan +``` + +Or using Sail: +``` +sail bin phpunit +sail bin phpstan +``` + +## License + +This package is open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/art/logo.svg b/art/logo.svg new file mode 100644 index 0000000..1af3d57 --- /dev/null +++ b/art/logo.svg @@ -0,0 +1,13208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7504f6b --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "yourivw/sailor", + "description": "Extension to Laravel Sail.", + "keywords": [ + "laravel", + "sailor" + ], + "license": "MIT", + "support": { + "issues": "https://github.com/yourivw/sailor/issues", + "source": "https://github.com/yourivw/sailor" + }, + "authors": [ + { + "name": "Youri van Weegberg", + "email": "youri@yourivw.nl" + } + ], + "require": { + "php": "^8.1", + "illuminate/support": "^10.0|^11.0", + "laravel/sail": "^1.26", + "symfony/yaml": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^8.21|^9.0", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "Yourivw\\Sailor\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Yourivw\\Sailor\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Yourivw\\Sailor\\SailorServiceProvider" + ], + "aliases": { + "Sailor": "Yourivw\\Sailor\\Facades\\Sailor" + } + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ec31607 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + paths: + - src + level: 0 \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..91e98c7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./src + + + src/SailorServiceProvider.php + src/Facades/Sailor.php + + + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..46946ee --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "exclude": [ + "workbench" + ] +} \ No newline at end of file diff --git a/src/Console/AddCommand.php b/src/Console/AddCommand.php new file mode 100644 index 0000000..d9e766b --- /dev/null +++ b/src/Console/AddCommand.php @@ -0,0 +1,90 @@ +sailorManager->validateServices(); + + $composePath = base_path('docker-compose.yml'); + if (! File::exists($composePath)) { + $this->error('A docker-compose file does not exist yet, run sailor:install first.'); + + return 1; + } + + if ($this->argument('services')) { + $services = collect($this->argument('services') == 'none' ? [] : explode(',', $this->argument('services'))); + } elseif ($this->option('no-interaction')) { + $services = $this->sailorManager->allDefaultServices(); + } else { + $services = $this->gatherServicesInteractively(); + } + + $invalidServices = $services->diff($this->sailorManager->allServices()); + if ($invalidServices->isNotEmpty()) { + $this->error('Invalid services ['.$invalidServices->implode(', ').'].'); + + return 1; + } + + $sailServices = $this->sailorManager->filterSailServices($services); + $sailorServices = $this->sailorManager->filterSailorServices($services); + + if ($sailServices->isNotEmpty()) { + $buffer = new BufferedOutput( + (int) $this->output->getVerbosity(), + $this->output->isDecorated(), + $this->output->getFormatter() + ); + + $sailResult = $this->runCommand('sail:add', [ + 'services' => $sailServices->join(','), + ], $buffer); + + if ($sailResult > 0) { + $this->error('An error occurred while adding Sail services.'); + $this->output->write($buffer->fetch()); + + return $sailResult; + } + } + + if (isset($buffer)) { + $this->output->write($buffer->fetch()); + } + + if ($sailorServices->isNotEmpty()) { + $this->buildSailorDockerCompose($sailorServices); + } + + $this->output->writeln(''); + $this->info('Additional Sailor services installed successfully.'); + } +} diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php new file mode 100644 index 0000000..698c551 --- /dev/null +++ b/src/Console/BaseCommand.php @@ -0,0 +1,167 @@ +sailorManager = $sailorManager; + $this->yamlParser = $yamlParser; + } + + /** + * Check wheter Laravel Prompts (multiselect) is installed. + */ + protected function multiselectExists(): bool + { + return function_exists('\Laravel\Prompts\multiselect'); + } + + /** + * Gather the desired Sail services using an interactive prompt. + */ + protected function gatherServicesInteractively(): Collection + { + $availableServices = $this->sailorManager->allServices(); + + if ($this->multiselectExists()) { + return collect(\Laravel\Prompts\multiselect( + label: 'Which services would you like to install?', + options: $availableServices, + default: $this->sailorManager->allDefaultServices() + )); + } + + return collect($this->choice('Which services would you like to install?', $availableServices->toArray(), 0, null, true)); + } + + /** + * Build the Docker Compose file. + * + * @return void + */ + protected function buildSailorDockerCompose(Collection $services) + { + $dockerComposePath = $this->laravel->basePath('docker-compose.yml'); + + $compose = $this->yamlParser->parseFile($dockerComposePath); + $serviceName = $this->findLaravelServiceName($compose); + + // Adds the new services as dependencies of the laravel service... + if (is_null($serviceName) || ! array_key_exists($serviceName, $compose['services'])) { + $this->warn('Couldn\'t find the laravel service. Make sure you add ['.$services->implode(', ').'] to the depends_on config.'); + } else { + $compose['services'][$serviceName]['depends_on'] = collect($compose['services'][$serviceName]['depends_on'] ?? []) + ->merge($services->keys()) + ->unique() + ->values() + ->all(); + } + + // Add the services to the docker-compose.yml... + $addedServices = $services->filter(function (Serviceable $service) use ($compose) { + return ! array_key_exists($service->name(), $compose['services'] ?? []); + })->each(function (Serviceable $service) use (&$compose) { + $serviceName = $service->name(); + $compose['services'][$serviceName] = $this->yamlParser->parseFile($service->stubFilePath())[$serviceName]; + }); + + // Merge volumes... + $services->filter(function (Serviceable $service) { + return $service->needsVolume(); + })->filter(function (Serviceable $service) use ($compose) { + return ! array_key_exists($service->name(), $compose['volumes'] ?? []); + })->each(function (Serviceable $service) use (&$compose) { + $compose['volumes']["sailor-{$service->name()}"] = ['driver' => 'local']; + }); + + // If the list of volumes is empty, we can remove it... + if (empty($compose['volumes'])) { + unset($compose['volumes']); + } + + // Before saving, run the after callbacks for newly added services. + $addedServices->each(function (Serviceable $service) use (&$compose) { + $service->afterAdding($this, $compose); + }); + + File::put($this->laravel->basePath('docker-compose.yml'), $this->yamlParser->dump($compose, Yaml::DUMP_OBJECT_AS_MAP)); + } + + /** + * Find the current Laravel service name in the docker-compose file. + */ + protected function findLaravelServiceName(array $compose): ?string + { + return collect($compose['services']) + ->filter(function ($service) { + return Arr::get($service, 'environment.LARAVEL_SAIL') === 1; + }) + ->keys() + ->first(); + } + + /** + * Rename the Laravel service in the docker-compose and .env files. + */ + protected function renameLaravelService(string $newServiceName): bool + { + $dockerComposePath = $this->laravel->basePath('docker-compose.yml'); + $compose = $this->yamlParser->parseFile($dockerComposePath); + $currentServiceName = $this->findLaravelServiceName($compose); + if (is_null($currentServiceName)) { + $this->error('Laravel service was not found, and therefore could not be (re)named.'); + + return false; + } + + $services = collect($compose['services']) + ->mapWithKeys(function ($service, $name) use ($newServiceName, $currentServiceName, &$serviceFound) { + return [ + ($name === $currentServiceName ? $newServiceName : $name) => $service, + ]; + }); + + $compose['services'] = $services->toArray(); + File::put($dockerComposePath, $this->yamlParser->dump($compose, Yaml::DUMP_OBJECT_AS_MAP)); + + $environment = File::get($this->laravel->basePath('.env')); + + if (Str::contains($environment, 'APP_SERVICE')) { + $environment = preg_replace('/APP_SERVICE=.*/', 'APP_SERVICE='.$newServiceName, $environment); + } else { + $environment .= "\nAPP_SERVICE=".$newServiceName."\n"; + } + + File::put($this->laravel->basePath('.env'), $environment); + + return true; + } +} diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php new file mode 100644 index 0000000..78bebf1 --- /dev/null +++ b/src/Console/InstallCommand.php @@ -0,0 +1,98 @@ +sailorManager->validateServices(); + + $composePath = base_path('docker-compose.yml'); + if (File::exists($composePath)) { + $this->error('A docker-compose file already exists, an installation has run already.'); + + return 1; + } + + if ($this->option('name')) { + $serviceName = $this->option('name'); + } elseif ($this->option('no-interaction')) { + $serviceName = $this->sailorManager->defaultServiceName(); + } else { + $serviceName = $this->ask('What\'s the name for the Laravel service?', $this->sailorManager->defaultServiceName()); + } + + if ($this->option('with')) { + $services = collect($this->option('with') == 'none' ? [] : explode(',', $this->option('with'))); + } elseif ($this->option('no-interaction')) { + $services = $this->sailorManager->allDefaultServices(); + } else { + $services = $this->gatherServicesInteractively(); + } + + $invalidServices = $services->diff($this->sailorManager->allServices()); + if ($invalidServices->isNotEmpty()) { + $this->error('Invalid services ['.$invalidServices->implode(', ').'].'); + + return 1; + } + + $sailServices = $this->sailorManager->filterSailServices($services); + $sailorServices = $this->sailorManager->filterSailorServices($services); + + $buffer = new BufferedOutput( + (int) $this->output->getVerbosity(), + $this->output->isDecorated(), + $this->output->getFormatter() + ); + + $sailResult = $this->runCommand('sail:install', [ + '--with' => $sailServices->join(','), + '--devcontainer' => $this->option('devcontainer'), + ], $buffer); + + if ($sailResult > 0) { + $this->error('An error occurred while installing Sail.'); + $this->output->write($buffer->fetch()); + + return $sailResult; + } + + $this->output->write($buffer->fetch()); + + if ($sailorServices->isNotEmpty()) { + $this->buildSailorDockerCompose($sailorServices); + } + + $this->renameLaravelService($serviceName); + + $this->output->writeln(''); + $this->info('Sailor services installed successfully.'); + } +} diff --git a/src/Console/ListCommand.php b/src/Console/ListCommand.php new file mode 100644 index 0000000..4f4f7eb --- /dev/null +++ b/src/Console/ListCommand.php @@ -0,0 +1,61 @@ +sailorManager = $sailorManager; + } + + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'sailor:list'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'List the registered Sailor services'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $this->sailorManager->validateServices(); + + $rows = collect($this->sailorManager->services()) + ->map(function (Serviceable $service) { + return [ + $service->name(), + realpath($service->stubFilePath()), + $service->isUsedDefault() ? 'Yes' : 'No', + $service->needsVolume() ? 'Yes' : 'No', + ! empty($service->publishes()) ? 'Yes' : 'No', + ]; + }); + + $this->table(['Name', 'Stub file', 'Used default', 'Needs volume', 'Has publishable files'], $rows); + } +} diff --git a/src/Console/PublishCommand.php b/src/Console/PublishCommand.php new file mode 100644 index 0000000..79699ee --- /dev/null +++ b/src/Console/PublishCommand.php @@ -0,0 +1,108 @@ +sailorManager = $sailorManager; + } + + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'sailor:publish + {--services= : The name of the service(s) to publish files for} + '; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Publish the Sailor service files'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $this->sailorManager->validateServices(); + + $success = false; + + if ($serviceNames = $this->option('services')) { + $services = $this->sailorManager->services(); + + foreach (explode(',', $serviceNames) as $serviceName) { + $service = $services[$serviceName] ?? null; + if (! $service instanceof Serviceable) { + $this->error(sprintf('Service \'%s\' does not exist, no files published.', $serviceName)); + + continue; + } + + $this->publishService($service); + $success = true; + } + } else { + $this->publishAll(); + $success = true; + } + + if (! $success) { + $this->error('No files could be published.'); + + return 1; + } + } + + /** + * Publish for all services. + * + * @return void + */ + protected function publishAll() + { + $this->call('vendor:publish', ['--tag' => 'sailor']); + + /** @var Serviceable $service */ + foreach ($this->sailorManager->services() as $service) { + $service->afterPublishing($this); + } + + $this->info('Files published for all services.'); + } + + /** + * Publish for a specified service. + * + * @return void + */ + protected function publishService(Serviceable $service) + { + $this->call('vendor:publish', ['--tag' => 'sailor-'.$service->name()]); + + $service->afterPublishing($this); + + $this->info(sprintf('Files published for service \'%s\'.', $service->name())); + } +} diff --git a/src/Console/RenameCommand.php b/src/Console/RenameCommand.php new file mode 100644 index 0000000..4474373 --- /dev/null +++ b/src/Console/RenameCommand.php @@ -0,0 +1,58 @@ +sailorManager->validateServices(); + + $composePath = base_path('docker-compose.yml'); + if (! File::exists($composePath)) { + $this->error('A docker-compose file does not exist yet, run sailor:install first.'); + + return 1; + } + + if ($this->argument('name')) { + $serviceName = $this->argument('name'); + } elseif ($this->option('no-interaction')) { + $serviceName = $this->sailorManager->defaultServiceName(); + } else { + $serviceName = $this->ask('What\'s the new name for the Laravel service?', $this->sailorManager->defaultServiceName()); + } + + if (! $this->renameLaravelService($serviceName)) { + $this->error('Failed to rename the Laravel service in the docker-compose file.'); + + return 1; + } + + $this->output->writeln(''); + $this->info('Laravel Sail service renamed successfully.'); + } +} diff --git a/src/Contracts/Serviceable.php b/src/Contracts/Serviceable.php new file mode 100644 index 0000000..6b4c42a --- /dev/null +++ b/src/Contracts/Serviceable.php @@ -0,0 +1,47 @@ +publishCallback = $publishCallback; + } + + /** + * Register a new Sailor service with the manager. + * + * @return void + */ + public function register(Serviceable $service) + { + $this->services[$service->name()] = $service; + + if (! empty($publishes = $service->publishes())) { + ($this->publishCallback)($publishes, ['sailor', 'sailor-'.$service->name()]); + } + } + + /** + * Return the registered Sailor services. + * + * @return array|Serviceable[] + */ + public function services(): array + { + return $this->services; + } + + /** + * Return the registered Sailor service names. + */ + public function serviceNames(): array + { + return array_keys($this->services); + } + + /** + * Return the Sailor default services. + */ + public function defaultServices(): array + { + return collect($this->services) + ->filter(function (Serviceable $service) { + return $service->isUsedDefault(); + }) + ->keys() + ->toArray(); + } + + /** + * Run validation on all the registered services. + */ + public function validateServices(): bool + { + foreach ($this->services() as $service) { + $this->validateService($service); + } + + return true; + } + + /** + * Validate the service, by checking the existance and validity of the stub file. + * + * @return void + * + * @throws SailorException + */ + public function validateService(Serviceable $servicable) + { + if (! File::exists($servicable->stubFilePath())) { + throw new SailorException(sprintf('Stub file not found for \'%s\' service.', $servicable->name())); + } + + try { + $yamlFile = app(Yaml::class)->parseFile($servicable->stubFilePath()); + } catch (ParseException $exception) { + throw new SailorException( + sprintf('Stub file for \'%s\' service is invalid: '.$exception->getMessage(), $servicable->name()), + 0, + $exception + ); + } + + if (! array_key_exists($servicable->name(), $yamlFile)) { + throw new SailorException(sprintf('Stub file for \'%s\' service is invalid, missing service key.', $servicable->name())); + } + } + + /** + * Set the wanted default Sail service(s). Invalid service names are filtered out. + * + * @param string|array $defaults + * @return void + */ + public function setSailDefaultServices(...$defaults) + { + if (is_array($defaults[0])) { + $defaults = $defaults[0]; + } + + $this->sailDefaultServices = array_filter($defaults, function ($default) { + if (! is_string($default)) { + return false; + } + + return in_array($default, $this->sailServices()); + }); + } + + /** + * Clear the list of Sail default services. + * + * @return void + */ + public function clearSailDefaultServices() + { + $this->sailDefaultServices = []; + } + + /** + * Get all available Sail services. + * + * @throws SailorException + */ + public function sailServices(): array + { + if (! is_null($this->sailServices)) { + return $this->sailServices; + } + + try { + /** @var ReflectionProperty $property */ + $property = app()->make(ReflectionProperty::class, [ + 'class' => InteractsWithDockerComposeServices::class, + 'property' => 'services'] + ); + + return $this->sailServices = (array) $property->getDefaultValue(); + } catch (Throwable $exception) { + throw new SailorException('Failed to retrieve Sail services: '.$exception->getMessage(), 0, $exception); + } + } + + /** + * Get the Sail services listed as default. + * + * @throws SailorException + */ + public function sailDefaultServices(): array + { + if (! is_null($this->sailDefaultServices)) { + return $this->sailDefaultServices; + } + + try { + /** @var ReflectionProperty $property */ + $property = app()->make(ReflectionProperty::class, [ + 'class' => InteractsWithDockerComposeServices::class, + 'property' => 'defaultServices'] + ); + + return $this->sailDefaultServices = (array) $property->getDefaultValue(); + } catch (Throwable $exception) { + throw new SailorException('Failed to retrieve Sail default services: '.$exception->getMessage(), 0, $exception); + } + } + + /** + * Get the merged list of all services. + */ + public function allServices(): Collection + { + $sailorServices = $this->serviceNames(); + + return collect($this->sailServices()) + ->filter(function (string $service) use ($sailorServices) { + return ! in_array($service, $sailorServices); + }) + ->merge($sailorServices); + } + + /** + * Get the merged list of all default services. + */ + public function allDefaultServices(): Collection + { + return collect($this->sailDefaultServices()) + ->merge($this->defaultServices()) + ->unique() + ->values(); + } + + /** + * Filter a list of services, which are handled by Sail. A service overridden through Sailor is skipped. + */ + public function filterSailServices(Collection $services): Collection + { + return $services->filter(function (string $serviceName) { + return in_array($serviceName, $this->sailServices()) && ! in_array($serviceName, $this->serviceNames()); + }); + } + + /** + * Filter a list of services, which are handled by Sailor. + */ + public function filterSailorServices(Collection $services): Collection + { + return $services->filter(function (string $serviceName) { + return in_array($serviceName, $this->serviceNames()); + })->mapWithKeys(function (string $serviceName) { + return [$serviceName => $this->services()[$serviceName]]; + }); + } + + /** + * Get the default name for the Laravel service. + */ + public function defaultServiceName(): string + { + return $this->defaultServiceName; + } + + /** + * Set the default name for the Laravel service. + */ + public function setDefaultServiceName(string $serviceName) + { + $this->defaultServiceName = $serviceName; + } +} diff --git a/src/SailorService.php b/src/SailorService.php new file mode 100644 index 0000000..fea7809 --- /dev/null +++ b/src/SailorService.php @@ -0,0 +1,215 @@ +name = $name; + $this->stubFilePath = $stubFilePath; + } + + /** + * Get the service name. + */ + public function name(): string + { + return $this->name; + } + + /** + * Get the service stub file path. + */ + public function stubFilePath(): string + { + return $this->stubFilePath; + } + + /** + * Include a Docker volume for the service. + */ + public function withVolume(bool $needsVolume = true): static + { + $this->needsVolume = $needsVolume; + + return $this; + } + + /** + * Get whether the service needs a Docker volume. + */ + public function needsVolume(): bool + { + return $this->needsVolume; + } + + /** + * Get whether the service should be installed by default. + */ + public function useDefault(bool $isUsedDefault = true): static + { + $this->isUsedDefault = $isUsedDefault; + + return $this; + } + + /** + * Set the service as default for installation. + */ + public function isUsedDefault(): bool + { + return $this->isUsedDefault; + } + + /** + * Set the publishable files. + */ + public function withPublishable(array $publishes): static + { + $this->publishes = $publishes; + + return $this; + } + + /** + * Get the publishable files. + */ + public function publishes(): array + { + return $this->publishes; + } + + /** + * Set a callback to be ran after this service was added. Only runs when the service did not yet exist in the Dockerfile. + * The callback receives two argument, the executing Artisan command, and the array containing the content of the Dockerfile + * formatted by Symfony\Component\Yaml\Yaml. The contents of this array can be modified and will be saved. + * + * @return $this + */ + public function callAfterAdding(Closure $afterAddingCallback): static + { + $this->afterAddingCallback = $afterAddingCallback; + + return $this; + } + + /** + * Callback ran after this service was added. Only runs when the service did not yet exist in the Dockerfile. + * + * @return void + * + * @throws SailorException + */ + public function afterAdding(Command $command, array &$compose) + { + if (is_callable($this->afterAddingCallback)) { + try { + ($this->afterAddingCallback)($command, $compose); + } catch (Throwable $exception) { + throw new SailorException( + 'Failed to run callback after adding service: '.$exception->getMessage(), + 0, + $exception + ); + } + } + } + + /** + * Set a callback to be ran after this service files were published. The callback receives one argument, the executing + * Artisan command. + * + * @return $this + */ + public function callAfterPublishing(Closure $afterPublishingCallback): static + { + $this->afterPublishingCallback = $afterPublishingCallback; + + return $this; + } + + /** + * Callback ran after this service files were published. + * + * @return void + * + * @throws SailorException + */ + public function afterPublishing(Command $command) + { + if (is_callable($this->afterPublishingCallback)) { + try { + ($this->afterPublishingCallback)($command); + } catch (Throwable $exception) { + throw new SailorException( + 'Failed to run callback for publishing service files: '.$exception->getMessage(), + 0, + $exception + ); + } + } + } + + /** + * Internally register the current service with the manager. + * + * @return void + */ + public function register() + { + app(SailorManager::class)->register($this); + } + + /** + * Fluently create a new service instance. + */ + public static function create(string $name, string $stubFilePath): self + { + return new self($name, $stubFilePath); + } +} diff --git a/src/SailorServiceProvider.php b/src/SailorServiceProvider.php new file mode 100644 index 0000000..03e3a64 --- /dev/null +++ b/src/SailorServiceProvider.php @@ -0,0 +1,72 @@ +app->singleton(SailorManager::class, function () { + return new SailorManager(Closure::fromCallable([$this, 'publishes'])); + }); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + $this->registerCommands(); + } + + /** + * Register the console commands for the package. + * + * @return void + */ + protected function registerCommands() + { + if ($this->app->runningInConsole()) { + $this->commands([ + AddCommand::class, + InstallCommand::class, + ListCommand::class, + PublishCommand::class, + RenameCommand::class, + ]); + } + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [ + SailorManager::class, + AddCommand::class, + InstallCommand::class, + ListCommand::class, + PublishCommand::class, + RenameCommand::class, + ]; + } +} diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..516ece0 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,3 @@ +providers: + - Yourivw\Sailor\SailorServiceProvider + - Laravel\Sail\SailServiceProvider \ No newline at end of file diff --git a/tests/Feature/AddCommandTest.php b/tests/Feature/AddCommandTest.php new file mode 100644 index 0000000..ae5d05e --- /dev/null +++ b/tests/Feature/AddCommandTest.php @@ -0,0 +1,70 @@ +registerTestingServices(); + $this->createDockerComposeFile(); + + $sailServiceName = $this->getFirstSailService(); + $this->artisan('sailor:add test1,'.$sailServiceName) + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEqualsCanonicalizing(['laravel.test', $sailServiceName, 'test1', 'test2'], array_keys($compose['services'])); + $this->assertEqualsCanonicalizing([$sailServiceName, 'test1', 'test2'], $compose['services']['laravel.test']['depends_on']); + $this->assertArrayHasKey('sail-test2', $compose['volumes']); + } + + public function testSuccessfulAddingUsingDefaults() + { + $this->registerTestingServices(); + $this->createDockerComposeFile(); + + $sailServiceName = $this->getFirstSailService(); + + /** @var SailorManager $manager */ + $manager = app(SailorManager::class); + $manager->setSailDefaultServices($sailServiceName); + + $this->artisan('sailor:add --no-interaction') + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEqualsCanonicalizing(['laravel.test', $sailServiceName, 'test1', 'test2'], array_keys($compose['services'])); + $this->assertEqualsCanonicalizing([$sailServiceName, 'test1', 'test2'], $compose['services']['laravel.test']['depends_on']); + $this->assertArrayHasKey('sail-test2', $compose['volumes']); + } + + public function testSuccessfulAddingUsingInput() + { + $this->registerTestingServices(); + $this->createDockerComposeFile(); + + $sailServiceName = $this->getFirstSailService(); + + $this->artisan('sailor:add') + ->assertSuccessful() + ->expectsQuestion('Which services would you like to install?', [$sailServiceName, 'test1']); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEqualsCanonicalizing(['laravel.test', $sailServiceName, 'test1', 'test2'], array_keys($compose['services'])); + $this->assertEqualsCanonicalizing([$sailServiceName, 'test1', 'test2'], $compose['services']['laravel.test']['depends_on']); + $this->assertArrayHasKey('sail-test2', $compose['volumes']); + } +} diff --git a/tests/Feature/CommandTestCase.php b/tests/Feature/CommandTestCase.php new file mode 100644 index 0000000..575dba6 --- /dev/null +++ b/tests/Feature/CommandTestCase.php @@ -0,0 +1,159 @@ +afterApplicationCreated(function () { + $this->testDir = $this->app->basePath('/tests/tmp'); + + if (File::exists($this->testDir)) { + File::deleteDirectory($this->testDir); + } + + File::makeDirectory($this->testDir); + File::copy($this->app->basePath('.env.example'), $this->testDir.'/.env'); + + $this->originalBasePath = $this->app->basePath(); + $this->app->setBasePath($this->testDir); + }); + + $this->beforeApplicationDestroyed(function () { + $this->app->setBasePath($this->originalBasePath); + + if (File::exists($this->testDir)) { + File::deleteDirectory($this->testDir); + } + }); + + parent::setUp(); + } + + protected function createDockerComposeFile() + { + $compose = [ + 'services' => [ + 'laravel.test' => [ + 'build' => [ + 'context' => './vendor/laravel/sail/runtimes/8.3', + 'dockerfile' => 'Dockerfile', + 'args' => [ + 'WWWGROUP' => '${WWWGROUP}', + ], + ], + 'image' => 'sail-8.3/app', + 'extra_hosts' => [ + 0 => 'host.docker.internal:host-gateway', + ], + 'ports' => [ + 0 => '${APP_PORT:-80}:80', + 1 => '${VITE_PORT:-5173}:${VITE_PORT:-5173}', + ], + 'environment' => [ + 'WWWUSER' => '${WWWUSER}', + 'LARAVEL_SAIL' => 1, + 'XDEBUG_MODE' => '${SAIL_XDEBUG_MODE:-off}', + 'XDEBUG_CONFIG' => '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}', + 'IGNITION_LOCAL_SITES_PATH' => '${PWD}', + ], + 'volumes' => [ + 0 => '.:/var/www/html', + ], + 'networks' => [ + 0 => 'sail', + ], + 'depends_on' => [ + 0 => 'test2', + ], + ], + 'test2' => [ + 'image' => 'testing:latest', + 'ports' => [ + 0 => '0002:0002', + ], + 'networks' => [ + 0 => 'sail', + ], + ], + ], + 'networks' => [ + 'sail' => [ + 'driver' => 'bridge', + ], + ], + 'volumes' => [ + 'sail-test2' => [ + 'driver' => 'local', + ], + ], + ]; + + file_put_contents($this->app->basePath('docker-compose.yml'), Yaml::dump($compose, Yaml::DUMP_OBJECT_AS_MAP)); + } + + protected function registerTestingServices(?Closure $test1AddCallback = null, ?Closure $test2PublishCallback = null) + { + $test1StubPath = $this->app->basePath('test1.stub'); + $test1Stub = ['test1' => [ + 'image' => 'testing:latest', + 'ports' => ['0001:0001'], + 'networks' => ['sail'], + ]]; + file_put_contents($test1StubPath, Yaml::dump($test1Stub, Yaml::DUMP_OBJECT_AS_MAP)); + + $test2StubPath = $this->app->basePath('test2.stub'); + $test2Stub = ['test2' => [ + 'image' => 'testing:latest', + 'ports' => ['0002:0002'], + 'networks' => ['sail'], + ]]; + file_put_contents($test2StubPath, Yaml::dump($test2Stub, Yaml::DUMP_OBJECT_AS_MAP)); + + SailorService::create('test1', $test1StubPath) + ->useDefault() + ->callAfterAdding($test1AddCallback ?? function (Command $command, array $compose) { + // + }) + ->register(); + + SailorService::create('test2', $test2StubPath) + ->withVolume() + ->withPublishable([$test2StubPath => $this->app->basePath('published/test2.stub')]) + ->callAfterPublishing($test2PublishCallback ?? function (Command $command) { + // + }) + ->register(); + } + + protected function getFirstSailService() + { + if (isset(static::$firstSailService)) { + return static::$firstSailService; + } + + /** @var SailorManager $manager */ + $manager = app(SailorManager::class); + + return static::$firstSailService = Arr::first($manager->sailServices()); + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php new file mode 100644 index 0000000..6aa4ae6 --- /dev/null +++ b/tests/Feature/InstallCommandTest.php @@ -0,0 +1,88 @@ +partialMock(\stdClass::class, function (MockInterface $mock) { + $mock->expects('__invoke') + ->withArgs([Mockery::type(InstallCommand::class), Mockery::type('array')]); + }); + + $this->registerTestingServices(Closure::fromCallable([$callback, '__invoke'])); + + $sailServiceName = $this->getFirstSailService(); + + $this->artisan('sailor:install --name=laravel.testing --with=test1,test2,'.$sailServiceName) + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEquals(['laravel.testing', $sailServiceName, 'test1', 'test2'], array_keys($compose['services'])); + $this->assertEquals([$sailServiceName, 'test1', 'test2'], $compose['services']['laravel.testing']['depends_on']); + $this->assertArrayHasKey('sailor-test2', $compose['volumes']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } + + public function testSuccessfulInstallationUsingDefaults() + { + $this->registerTestingServices(); + + $sailServiceName = $this->getFirstSailService(); + + /** @var SailorManager $manager */ + $manager = app(SailorManager::class); + $manager->setDefaultServiceName('laravel.testing'); + $manager->setSailDefaultServices($sailServiceName); + + $this->artisan('sailor:install --no-interaction') + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEquals(['laravel.testing', $sailServiceName, 'test1'], array_keys($compose['services'])); + $this->assertEquals([$sailServiceName, 'test1'], $compose['services']['laravel.testing']['depends_on']); + $this->assertArrayNotHasKey('sailor-test2', $compose['volumes']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } + + public function testSuccessfulInstallationUsingInput() + { + $this->registerTestingServices(); + + $sailServiceName = $this->getFirstSailService(); + + $this->artisan('sailor:install') + ->assertSuccessful() + ->expectsQuestion('What\'s the name for the Laravel service?', 'laravel.testing') + ->expectsQuestion('Which services would you like to install?', [$sailServiceName, 'test2']); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertEquals(['laravel.testing', $sailServiceName, 'test2'], array_keys($compose['services'])); + $this->assertEquals([$sailServiceName, 'test2'], $compose['services']['laravel.testing']['depends_on']); + $this->assertArrayHasKey('sailor-test2', $compose['volumes']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } +} diff --git a/tests/Feature/ListCommandTest.php b/tests/Feature/ListCommandTest.php new file mode 100644 index 0000000..9cf8a41 --- /dev/null +++ b/tests/Feature/ListCommandTest.php @@ -0,0 +1,21 @@ +registerTestingServices(); + + $expectedHeaders = ['Name', 'Stub file', 'Used default', 'Needs volume', 'Has publishable files']; + $expectedRows = [ + ['test1', $this->app->basePath('test1.stub'), 'Yes', 'No', 'No'], + ['test2', $this->app->basePath('test2.stub'), 'No', 'Yes', 'Yes'], + ]; + + $this->artisan('sailor:list') + ->assertSuccessful() + ->expectsTable($expectedHeaders, $expectedRows); + } +} diff --git a/tests/Feature/PublishCommandTest.php b/tests/Feature/PublishCommandTest.php new file mode 100644 index 0000000..bf9f8a1 --- /dev/null +++ b/tests/Feature/PublishCommandTest.php @@ -0,0 +1,27 @@ +partialMock(stdClass::class, function (MockInterface $mock) { + $mock->expects('__invoke') + ->withArgs([Mockery::type(PublishCommand::class)]); + }); + + $this->registerTestingServices(null, Closure::fromCallable([$callback, '__invoke'])); + + $this->artisan('sailor:publish') + ->assertSuccessful(); + + $this->assertFileExists($this->app->basePath('published/test2.stub')); + } +} diff --git a/tests/Feature/RenameCommandTest.php b/tests/Feature/RenameCommandTest.php new file mode 100644 index 0000000..cfa0525 --- /dev/null +++ b/tests/Feature/RenameCommandTest.php @@ -0,0 +1,90 @@ +createDockerComposeFile(); + + $this->artisan('sailor:rename laravel.testing') + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertArrayHasKey('laravel.testing', $compose['services']); + $this->assertArrayNotHasKey('laravel.test', $compose['services']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } + + public function testSuccessfulRenamingUsingDefault() + { + $this->createDockerComposeFile(); + + /** @var SailorManager $manager */ + $manager = app(SailorManager::class); + $manager->setDefaultServiceName('laravel.testing'); + + $this->artisan('sailor:rename --no-interaction') + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertArrayHasKey('laravel.testing', $compose['services']); + $this->assertArrayNotHasKey('laravel.test', $compose['services']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } + + public function testSuccessfulRenamingUsingInput() + { + $this->registerTestingServices(); + $this->createDockerComposeFile(); + + $this->artisan('sailor:rename') + ->assertSuccessful() + ->expectsQuestion('What\'s the new name for the Laravel service?', 'laravel.testing'); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertArrayHasKey('laravel.testing', $compose['services']); + $this->assertArrayNotHasKey('laravel.test', $compose['services']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } + + public function testReplacesNameInEnvFile() + { + $this->createDockerComposeFile(); + + $env = file_get_contents($this->app->basePath('.env')); + file_put_contents($this->app->basePath('.env'), $env."\n\nAPP_SERVICE=laravel.test"); + + $this->artisan('sailor:rename laravel.testing') + ->assertSuccessful(); + + $dockerComposePath = $this->app->basePath('docker-compose.yml'); + $this->assertFileExists($dockerComposePath); + $compose = Yaml::parseFile($dockerComposePath); + + $this->assertArrayHasKey('laravel.testing', $compose['services']); + $this->assertArrayNotHasKey('laravel.test', $compose['services']); + + $envPath = $this->app->basePath('.env'); + $this->assertStringContainsString('APP_SERVICE=laravel.testing', file_get_contents($envPath)); + } +} diff --git a/tests/Unit/AddCommandTest.php b/tests/Unit/AddCommandTest.php new file mode 100644 index 0000000..f36b061 --- /dev/null +++ b/tests/Unit/AddCommandTest.php @@ -0,0 +1,155 @@ +getMocks(); + + $manager->expects('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->shouldReceive('argument') + ->with('services') + ->andReturn('mysql,test1'); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql', 'test1'])); + + $manager->shouldReceive('filterSailServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailorServices') + ->andReturn($expectedSailorServices = collect(['test1'])); + + $command->expects('runCommand') + ->withSomeOfArgs('sail:add', ['services' => 'mysql']) + ->andReturn(0); + + $command->expects('buildSailorDockerCompose') + ->with($expectedSailorServices); + + $command->expects('info'); + + $this->assertContains($command->handle(), [null, 0]); + } + + public function testMissingDockerComposeReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::expects('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnFalse(); + + $command->expects('error'); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + public function testInvalidServiceReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->shouldReceive('argument') + ->with('services') + ->andReturn('mysql,test99'); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql', 'test1'])); + + $command->expects('error') + ->with(Mockery::pattern('/test99/')); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + public function testSailAddingFailureReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->shouldReceive('argument') + ->with('services') + ->andReturn('mysql'); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailorServices') + ->andReturn(collect([])); + + $command->expects('runCommand') + ->withSomeOfArgs('sail:add', ['services' => 'mysql']) + ->andReturn(9); + + $command->expects('error'); + + $this->assertEquals(9, $command->handle()); + } + + /** + * @return (AddCommand|SailorManager|MockInterface)[] + */ + protected function getMocks() + { + /** @var AddCommand|MockInterface $command */ + $command = $this->partialMock(AddCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + + /** @var Yaml|MockInterface $yamlParser */ + $yamlParser = $this->partialMock(Yaml::class); + + $command->__construct($manager, $yamlParser); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + return [$command, $manager]; + } +} diff --git a/tests/Unit/BaseCommandTest.php b/tests/Unit/BaseCommandTest.php new file mode 100644 index 0000000..c1de5aa --- /dev/null +++ b/tests/Unit/BaseCommandTest.php @@ -0,0 +1,158 @@ +getMocks(); + + $manager->shouldReceive('allServices') + ->andReturn(collect([])); + + $command->shouldReceive('multiselectExists') + ->andReturnFalse(); + + $command->expects('choice') + ->andReturn([1]); + + $this->assertEquals(collect([1]), $command->gatherServicesInteractively()); + } + + public function testBuildSailorDockerComposeMissingLaravelServiceShowsWarning() + { + [$command, $manager, $yamlParser] = $this->getMocks(); + + $compose = [ + 'services' => [ + 'laravel.test' => [], + ], + 'volumes' => [ + 'sail-mysql' => [ + 'driver' => 'local', + ], + ], + ]; + + $yamlParser->shouldReceive('parseFile') + ->andReturn($compose); + + $command->shouldReceive('findLaravelServiceName') + ->andReturnNull(); + + $command->expects('warn'); + + $yamlParser->expects('dump') + ->andReturn('test'); + + File::expects('put') + ->with(base_path('docker-compose.yml'), 'test') + ->andReturnTrue(); + + $command->buildSailorDockerCompose(collect([])); + } + + public function testBuildSailorDockerComposeRemovesEmptyVolumes() + { + [$command, $manager, $yamlParser] = $this->getMocks(); + + $compose = [ + 'services' => [ + 'laravel.test' => [ + 'depends_on' => [], + ], + ], + 'volumes' => [], + ]; + + $yamlParser->shouldReceive('parseFile') + ->andReturn($compose); + + $command->shouldReceive('findLaravelServiceName') + ->andReturn('laravel.test'); + + unset($compose['volumes']); + + $yamlParser->expects('dump') + ->withSomeOfArgs($compose) + ->andReturn('test'); + + File::expects('put') + ->with(base_path('docker-compose.yml'), 'test') + ->andReturnTrue(); + + $command->buildSailorDockerCompose(collect([])); + } + + public function testRenameLaravelServiceNotFoundShowsError() + { + [$command, $manager, $yamlParser] = $this->getMocks(); + + $yamlParser->shouldReceive('parseFile') + ->andReturn(['services' => []]); + + $command->expects('error'); + + $this->assertFalse($command->renameLaravelService('')); + } + + /** + * @return (TestableBaseCommand|SailorManager|Yaml|MockInterface)[] + */ + protected function getMocks() + { + /** @var TestableBaseCommand|MockInterface $command */ + $command = $this->partialMock(TestableBaseCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + + /** @var Yaml|MockInterface $yamlParser */ + $yamlParser = $this->partialMock(Yaml::class); + + $command->__construct($manager, $yamlParser); + $command->setLaravel(app()); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + return [$command, $manager, $yamlParser]; + } +} + +class TestableBaseCommand extends BaseCommand +{ + public function gatherServicesInteractively(): Collection + { + return parent::gatherServicesInteractively(); + } + + public function buildSailorDockerCompose(Collection $services) + { + return parent::buildSailorDockerCompose($services); + } + + public function renameLaravelService(string $newServiceName): bool + { + return parent::renameLaravelService($newServiceName); + } +} diff --git a/tests/Unit/InstallCommandTest.php b/tests/Unit/InstallCommandTest.php new file mode 100644 index 0000000..a6972c7 --- /dev/null +++ b/tests/Unit/InstallCommandTest.php @@ -0,0 +1,184 @@ +getMocks(); + + $manager->expects('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnFalse(); + + $command->shouldReceive('option') + ->with('name') + ->andReturn('laravel.testing'); + + $command->shouldReceive('option') + ->with('with') + ->andReturn('mysql,test1'); + + $command->shouldReceive('option') + ->with('devcontainer') + ->andReturnFalse(); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql', 'test1'])); + + $manager->shouldReceive('filterSailServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailorServices') + ->andReturn($expectedSailorServices = collect(['test1'])); + + $command->expects('runCommand') + ->withSomeOfArgs('sail:install', [ + '--with' => 'mysql', + '--devcontainer' => false, + ]) + ->andReturn(0); + + $command->expects('buildSailorDockerCompose') + ->with($expectedSailorServices); + + $command->expects('renameLaravelService') + ->with('laravel.testing'); + + $command->expects('info'); + + $this->assertContains($command->handle(), [null, 0]); + } + + public function testExistingDockerComposeReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::expects('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->expects('error'); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + public function testInvalidServiceReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnFalse(); + + $command->shouldReceive('option') + ->with('name') + ->andReturn('laravel.testing'); + + $command->shouldReceive('option') + ->with('with') + ->andReturn('mysql,test99'); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql', 'test1'])); + + $command->expects('error') + ->with(Mockery::pattern('/test99/')); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + public function testSailInstallFailureReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnFalse(); + + $command->shouldReceive('option') + ->with('name') + ->andReturn('laravel.testing'); + + $command->shouldReceive('option') + ->with('with') + ->andReturn('mysql'); + + $command->shouldReceive('option') + ->with('devcontainer') + ->andReturnFalse(); + + $manager->shouldReceive('allServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailServices') + ->andReturn(collect(['mysql'])); + + $manager->shouldReceive('filterSailorServices') + ->andReturn(collect([])); + + $command->expects('runCommand') + ->withSomeOfArgs('sail:install', [ + '--with' => 'mysql', + '--devcontainer' => false, + ]) + ->andReturn(9); + + $command->expects('error'); + + $this->assertEquals(9, $command->handle()); + } + + /** + * @return (InstallCommand|SailorManager|MockInterface)[] + */ + protected function getMocks() + { + /** @var InstallCommand|MockInterface $command */ + $command = $this->partialMock(InstallCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + + /** @var Yaml|MockInterface $yamlParser */ + $yamlParser = $this->partialMock(Yaml::class); + + $command->__construct($manager, $yamlParser); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + return [$command, $manager]; + } +} diff --git a/tests/Unit/ListCommandTest.php b/tests/Unit/ListCommandTest.php new file mode 100644 index 0000000..1596066 --- /dev/null +++ b/tests/Unit/ListCommandTest.php @@ -0,0 +1,67 @@ +getMocks(); + + $manager->expects('validateServices') + ->andReturnTrue(); + + $manager->shouldReceive('services') + ->andReturn(['test1' => $service1, 'test2' => $service2]); + + // Test all services get looped through, at least expecting name() call. + $service1->expects('name'); + $service2->expects('name'); + + $command->expects('table'); + + $this->assertContains($command->handle(), [null, 0]); + } + + /** + * @return (ListCommand|SailorManager|SailorService|MockInterface)[] + */ + protected function getMocks() + { + /** @var ListCommand|MockInterface $command */ + $command = $this->partialMock(ListCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + $command->__construct($manager); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + /** @var SailorService|MockInterface $service1 */ + $service1 = $this->partialMock(SailorService::class); + $service1->__construct('test1', 'test1.stub'); + + /** @var SailorService|MockInterface $service2 */ + $service2 = $this->partialMock(SailorService::class); + $service2->__construct('test2', 'test2.stub'); + + return [$command, $manager, $service1, $service2]; + } +} diff --git a/tests/Unit/PublishCommandTest.php b/tests/Unit/PublishCommandTest.php new file mode 100644 index 0000000..6b2b530 --- /dev/null +++ b/tests/Unit/PublishCommandTest.php @@ -0,0 +1,125 @@ +getMocks(); + + $manager->expects('validateServices') + ->andReturnTrue(); + + $command->shouldReceive('option') + ->with('services') + ->andReturn('test1,test2'); + + $manager->shouldReceive('services') + ->andReturn(['test1' => $service1, 'test2' => $service2]); + + $service1->expects('afterPublishing'); + $service2->expects('afterPublishing'); + + $command->expects('call') + ->with('vendor:publish', ['--tag' => 'sailor-test1']); + + $command->expects('call') + ->with('vendor:publish', ['--tag' => 'sailor-test2']); + + $command->expects('info') + ->twice(); + + $this->assertContains($command->handle(), [null, 0]); + } + + public function testSuccessfullyPublishingAll() + { + [$command, $manager, $service1, $service2] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + $command->shouldReceive('option') + ->with('services') + ->andReturnNull(); + + $command->expects('call') + ->with('vendor:publish', ['--tag' => 'sailor']); + + $manager->shouldReceive('services') + ->andReturn(['test1' => $service1, 'test2' => $service2]); + + $service1->expects('afterPublishing'); + $service2->expects('afterPublishing'); + + $command->expects('info'); + + $this->assertContains($command->handle(), [null, 0]); + } + + public function testUnknownServiceNameReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + $command->shouldReceive('option') + ->with('services') + ->andReturn('test1'); + + $manager->shouldReceive('services') + ->andReturn([]); + + $command->shouldNotReceive('call') + ->with('vendor:publish'); + + $command->expects('error') + ->twice(); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + /** + * @return (PublishCommand|SailorManager|SailorService|MockInterface)[] + */ + protected function getMocks() + { + /** @var PublishCommand|MockInterface $command */ + $command = $this->partialMock(PublishCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + $command->__construct($manager); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + /** @var SailorService|MockInterface $service1 */ + $service1 = $this->partialMock(SailorService::class); + $service1->__construct('test1', 'test1.stub'); + + /** @var SailorService|MockInterface $service2 */ + $service2 = $this->partialMock(SailorService::class); + $service2->__construct('test2', 'test2.stub'); + + return [$command, $manager, $service1, $service2]; + } +} diff --git a/tests/Unit/RenameCommandTest.php b/tests/Unit/RenameCommandTest.php new file mode 100644 index 0000000..00f063c --- /dev/null +++ b/tests/Unit/RenameCommandTest.php @@ -0,0 +1,109 @@ +getMocks(); + + $manager->expects('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->shouldReceive('argument') + ->with('name') + ->andReturn('laravel.testing'); + + $command->expects('renameLaravelService') + ->with('laravel.testing') + ->andReturnTrue(); + + $command->expects('info'); + + $this->assertContains($command->handle(), [null, 0]); + } + + public function testMissingDockerComposeReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::expects('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnFalse(); + + $command->expects('error'); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + public function testRenamingFailedReturnsError() + { + [$command, $manager] = $this->getMocks(); + + $manager->shouldReceive('validateServices') + ->andReturnTrue(); + + File::shouldReceive('exists') + ->with(base_path('docker-compose.yml')) + ->andReturnTrue(); + + $command->shouldReceive('argument') + ->with('name') + ->andReturn('laravel.testing'); + + $command->expects('renameLaravelService') + ->with('laravel.testing') + ->andReturnFalse(); + + $command->expects('error'); + + $this->assertGreaterThanOrEqual(1, $command->handle()); + } + + /** + * @return (RenameCommand|MockInterface)[] + */ + protected function getMocks() + { + /** @var RenameCommand|MockInterface $command */ + $command = $this->partialMock(RenameCommand::class); + $command->shouldAllowMockingProtectedMethods(); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class); + + /** @var Yaml|MockInterface $yamlParser */ + $yamlParser = $this->partialMock(Yaml::class); + + $command->__construct($manager, $yamlParser); + + $outputStyle = $this->app->make(OutputStyle::class, [ + 'input' => new StringInput(''), + 'output' => new BufferedOutput(), + ]); + $command->setOutput($outputStyle); + + return [$command, $manager]; + } +} diff --git a/tests/Unit/SailorManagerTest.php b/tests/Unit/SailorManagerTest.php new file mode 100644 index 0000000..ef49d95 --- /dev/null +++ b/tests/Unit/SailorManagerTest.php @@ -0,0 +1,327 @@ +withPublishable([__DIR__.'/test.stub' => __DIR__.'/test.stub']); + + $callback = $this->partialMock(\stdClass::class, function (MockInterface $mock) use ($service) { + $mock->expects('__invoke') + ->with($service->publishes(), ['sailor', 'sailor-'.$service->name()]); + }); + + $manager = new SailorManager(Closure::fromCallable([$callback, '__invoke'])); + $manager->register($service); + + $this->assertEquals([$service->name() => $service], $manager->services()); + $this->assertEquals([$service->name()], $manager->serviceNames()); + } + + public function testDefaultServiceNamesAreReturned() + { + $service1 = SailorService::create('test1', __DIR__.'/test1.stub'); + $service2 = SailorService::create('test2', __DIR__.'/test2.stub') + ->useDefault(); + + $manager = new SailorManager(fn () => null); + $manager->register($service1); + $manager->register($service2); + + $this->assertEquals([$service2->name()], $manager->defaultServices()); + } + + public function testAllServicesAreValidated() + { + $service1 = SailorService::create('test1', __DIR__.'/test1.stub'); + $service2 = SailorService::create('test2', __DIR__.'/test2.stub'); + + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) use ($service1, $service2) { + $mock->expects('validateService') + ->with($service1); + + $mock->expects('validateService') + ->with($service2); + }); + + $manager->register($service1); + $manager->register($service2); + + $this->assertTrue($manager->validateServices()); + } + + public function testSuccessfulServiceValidation() + { + $service = SailorService::create('test1', __DIR__.'/test1.stub'); + $manager = new SailorManager(fn () => null); + + File::expects('exists') + ->with(__DIR__.'/test1.stub') + ->andReturnTrue(); + + /** @var MockInterface $yaml */ + $yaml = $this->mock(Yaml::class, function (MockInterface $mock) { + $mock->expects('parseFile') + ->andReturn(['test1' => []]); + }); + + $this->app->bind(Yaml::class, fn () => $yaml); + + $manager->validateService($service); + + $this->app->offsetUnset(Yaml::class); + } + + public function testInvalidStubFilePathThrowsException() + { + $service = SailorService::create('test1', __DIR__.'/test1.stub'); + $manager = new SailorManager(fn () => null); + + File::expects('exists') + ->with(__DIR__.'/test1.stub') + ->andReturnFalse(); + + $this->assertThrows(fn () => $manager->validateService($service), SailorException::class, 'not found'); + } + + public function testInvalidStubFileThrowsException() + { + $service = SailorService::create('test1', __DIR__.'/test1.stub'); + $manager = new SailorManager(fn () => null); + + File::expects('exists') + ->with(__DIR__.'/test1.stub') + ->andReturnTrue(); + + /** @var MockInterface $yaml */ + $yaml = $this->mock(Yaml::class, function (MockInterface $mock) { + $mock->expects('parseFile') + ->andThrow(new ParseException('Test')); + }); + + $this->app->bind(Yaml::class, fn () => $yaml); + + $this->assertThrows(fn () => $manager->validateService($service), SailorException::class, 'invalid'); + + $this->app->offsetUnset(Yaml::class); + } + + public function testStubFileMissingServiceNameThrowsException() + { + $service = SailorService::create('test1', __DIR__.'/test1.stub'); + $manager = new SailorManager(fn () => null); + + File::expects('exists') + ->with(__DIR__.'/test1.stub') + ->andReturnTrue(); + + /** @var MockInterface $yaml */ + $yaml = $this->mock(Yaml::class, function (MockInterface $mock) { + $mock->expects('parseFile') + ->andReturn(['test' => []]); + }); + + $this->app->bind(Yaml::class, fn () => $yaml); + + $this->assertThrows(fn () => $manager->validateService($service), SailorException::class, 'missing service key'); + + $this->app->offsetUnset(Yaml::class); + } + + public function testSailDefaultServicesCanBeSet() + { + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) { + $mock->shouldReceive('sailServices') + ->andReturn(['test1', 'test2', 'test3']); + }); + + $manager->setSailDefaultServices(['test2', 'test3', 'test4', 5]); + + $this->assertEquals(['test2', 'test3'], $manager->sailDefaultServices()); + } + + public function testSailDefaultServicesCanBeCleared() + { + $manager = new SailorManager(fn () => null); + $manager->clearSailDefaultServices(); + + $this->assertEquals([], $manager->sailDefaultServices()); + } + + public function testSailServicesAreFoundUsingReflection() + { + $manager = new SailorManager(fn () => null); + + $property = new ReflectionProperty($manager, 'sailServices'); + $this->assertNull($property->getDefaultValue()); + + $services = $manager->sailServices(); + $this->assertNotEmpty($services); + $this->assertEquals($property->getValue($manager), $services); + } + + public function testSailServicesReflectionFailureThrowsException() + { + $manager = new SailorManager(fn () => null); + + $property = $this->partialMock(ReflectionProperty::class, function (MockInterface $mock) { + $mock->shouldReceive('getDefaultValue') + ->andThrow(new Exception('test')); + }); + + $this->app->bind(ReflectionProperty::class, fn () => $property); + + $this->assertThrows(fn () => $manager->sailServices(), SailorException::class); + + $this->app->offsetUnset(ReflectionProperty::class); + } + + public function testSailServicesAreFoundFromProperty() + { + $manager = new SailorManager(fn () => null); + + $property = new ReflectionProperty($manager, 'sailServices'); + $this->assertNull($property->getDefaultValue()); + + $expectation = ['test1', 'test2']; + $property->setValue($manager, $expectation); + $this->assertEquals($expectation, $manager->sailServices()); + } + + public function testSailDefaultServicesAreFoundUsingReflection() + { + $manager = new SailorManager(fn () => null); + + $property = new ReflectionProperty($manager, 'sailDefaultServices'); + $this->assertNull($property->getDefaultValue()); + + $services = $manager->sailDefaultServices(); + $this->assertNotEmpty($services); + $this->assertEquals($property->getValue($manager), $services); + } + + public function testSailDefaultServicesReflectionFailureThrowsException() + { + $manager = new SailorManager(fn () => null); + + $property = $this->partialMock(ReflectionProperty::class, function (MockInterface $mock) { + $mock->shouldReceive('getDefaultValue') + ->andThrow(new Exception('test')); + }); + + $this->app->bind(ReflectionProperty::class, fn () => $property); + + $this->assertThrows(fn () => $manager->sailDefaultServices(), SailorException::class); + + $this->app->offsetUnset(ReflectionProperty::class); + } + + public function testSailDefaultServicesAreFoundFromProperty() + { + $manager = new SailorManager(fn () => null); + + $property = new ReflectionProperty($manager, 'sailDefaultServices'); + $this->assertNull($property->getDefaultValue()); + + $expectation = ['test1', 'test2']; + $property->setValue($manager, $expectation); + $this->assertEquals($expectation, $manager->sailDefaultServices()); + } + + public function testAllServicesAreMerged() + { + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) { + $mock->shouldReceive('sailServices') + ->andReturn(['test1', 'test2', 'test3']); + + $mock->shouldReceive('serviceNames') + ->andReturn(['test1', 'test4', 'test5']); + }); + + // Validate two lists are merged, and that 'test1' is overridden by Sailor and therefore ordered in + // between the Sailor services. + $this->assertEquals(['test2', 'test3', 'test1', 'test4', 'test5'], $manager->allServices()->toArray()); + } + + public function testAllDefaultServicesAreMerged() + { + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) { + $mock->shouldReceive('sailDefaultServices') + ->andReturn(['test1', 'test2', 'test3']); + + $mock->shouldReceive('defaultServices') + ->andReturn(['test1', 'test4', 'test5']); + }); + + $this->assertEquals(['test1', 'test2', 'test3', 'test4', 'test5'], $manager->allDefaultServices()->toArray()); + } + + public function testSailServicesAreFilteredFromCollection() + { + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) { + $mock->shouldReceive('sailServices') + ->andReturn(['test1', 'test2', 'test3']); + + $mock->shouldReceive('serviceNames') + ->andReturn(['test3', 'test4', 'test5']); + }); + + // Validate only Sail services are returned, and service 'test3' overwritten by Sailor is filtered out. + $filtered = $manager->filterSailServices(collect(['test1', 'test2', 'test3', 'test4']))->toArray(); + $this->assertEquals(['test1', 'test2'], $filtered); + } + + public function testSailorServicesAreFilteredFromCollection() + { + /** @var SailorManager|MockInterface $manager */ + $manager = $this->partialMock(SailorManager::class, function (MockInterface $mock) { + $mock->shouldReceive('sailServices') + ->andReturn(['test1', 'test2', 'test3']); + + $mock->shouldReceive('serviceNames') + ->andReturn(['test3', 'test4', 'test5']); + + $mock->shouldReceive('services') + ->andReturn([ + 'test3' => 'test3', + 'test4' => 'test4', + 'test5' => 'test5', + ]); + }); + + $filtered = $manager->filterSailorServices(collect(['test1', 'test2', 'test3', 'test4']))->toArray(); + $this->assertEquals(['test3' => 'test3', 'test4' => 'test4'], $filtered); + } + + public function testSettingAndGettingDefaultServiceName() + { + $serviceName = 'test.service'; + + $manager = new SailorManager(fn () => null); + $manager->setDefaultServiceName($serviceName); + $this->assertEquals($serviceName, $manager->defaultServiceName()); + } +} diff --git a/tests/Unit/SailorServiceTest.php b/tests/Unit/SailorServiceTest.php new file mode 100644 index 0000000..99612e4 --- /dev/null +++ b/tests/Unit/SailorServiceTest.php @@ -0,0 +1,44 @@ +callAfterAdding(function () { + throw new Exception('test'); + }); + + /** @var InstallCommand|MockInterface $command */ + $command = $this->mock(InstallCommand::class); + $compose = []; + + $this->assertThrows(fn () => $service->afterAdding($command, $compose), SailorException::class, 'Failed to run callback'); + } + + public function testAfterPublishingCallbackFailureThrowsException() + { + $service = SailorService::create('test1', __DIR__.'/test1.stub') + ->callAfterPublishing(function () { + throw new Exception('test'); + }); + + /** @var InstallCommand|MockInterface $command */ + $command = $this->mock(InstallCommand::class); + $compose = []; + + $this->assertThrows(fn () => $service->afterPublishing($command, $compose), SailorException::class, 'Failed to run callback'); + } +}