diff --git a/.env.testing b/.env.testing index 1a7311798..d445b5afe 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b..6880b5ab0 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ public function mount() $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ public function submitPersistentVolume() $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ public function submitFileStorageDirectory() public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,13 @@ public function fileStorageHostPath(): string throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + $name = str($this->resource->name)->slug()->value(); + + return ($name ?: 'volume').'-data'; + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a..efe54a6a7 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ public function instantSave(int $storageId): void $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709..000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/composer.json b/composer.json index 18e125510..c0ffc6f07 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index 18c5260f3..b77aef46f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d511da9e5e82eade5aa7e5094c888ae", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b..59aa6f4c2 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 81c19bd3f..42ade3da6 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans

Mount a Docker volume inside the container.

- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 25a4fd749..dbe21fd7b 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,7 +154,24 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
Source Path - + @if (filled($form['hostPath'])) +
+
+ +
+ +
+ @else + - + @endif
diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php deleted file mode 100644 index d20e65294..000000000 --- a/tests/Browser/LoginTest.php +++ /dev/null @@ -1,27 +0,0 @@ -browse(callback: function (Browser $browser) { - $browser->loginWithRootUser() - ->assertPathIs('/') - ->assertSee('Dashboard'); - }); - } -} diff --git a/tests/Browser/Project/ProjectAddNewTest.php b/tests/Browser/Project/ProjectAddNewTest.php deleted file mode 100644 index b03313e4b..000000000 --- a/tests/Browser/Project/ProjectAddNewTest.php +++ /dev/null @@ -1,34 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->pressAndWaitFor('+ Add', 1) - ->assertSee('New Project') - ->screenshot('project-add-new-1') - ->type('name', 'Test Project') - ->screenshot('project-add-new-2') - ->press('Continue') - ->assertSee('Test Project.') - ->screenshot('project-add-new-3'); - }); - } -} diff --git a/tests/Browser/Project/ProjectSearchTest.php b/tests/Browser/Project/ProjectSearchTest.php deleted file mode 100644 index 7bc6796d1..000000000 --- a/tests/Browser/Project/ProjectSearchTest.php +++ /dev/null @@ -1,29 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->type('[x-model="search"]', 'joi43j4oi32j4o2') - ->assertSee('No project found with the search term "joi43j4oi32j4o2".') - ->screenshot('project-search-not-found'); - }); - } -} diff --git a/tests/Browser/Project/ProjectTest.php b/tests/Browser/Project/ProjectTest.php deleted file mode 100644 index 0d360e460..000000000 --- a/tests/Browser/Project/ProjectTest.php +++ /dev/null @@ -1,27 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->assertSee('Projects'); - }); - } -} diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/Browser/console/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/Browser/source/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php deleted file mode 100644 index 98e90fa79..000000000 --- a/tests/DuskTestCase.php +++ /dev/null @@ -1,57 +0,0 @@ -addArguments(collect([ - $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', - ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { - return $items->merge([ - '--disable-gpu', - '--headless=new', - ]); - })->all()); - - return RemoteWebDriver::create( - 'http://localhost:4444', - DesiredCapabilities::chrome()->setCapability( - ChromeOptions::CAPABILITY, - $options - ) - ); - } - - /** - * Determine if the browser window should start maximized. - */ - protected function baseUrl() - { - return 'http://localhost:8000'; - } -} diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index b22d99891..99133d794 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -33,6 +33,7 @@ ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); }); +use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; @@ -206,6 +207,62 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); }); +it('keeps bind mount source paths out of the add volume form', function () { + $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); + $volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); + + expect($storageView) + ->not->toContain('id="host_path"') + ->not->toContain('Swarm Mode detected') + ->and($volumesView) + ->toMatch('/]*canGate="update"[^>]*:canResource="\$resource"/') + ->toContain('The next deployment will use a named Docker volume instead.') + ->toContain('Data from the existing host directory will not be copied to the named volume.'); +}); + +it('creates named volumes without a host path in swarm mode', function () { + [$application] = createApplicationWithVolume(); + $application->persistentStorages()->delete(); + + Livewire::test(Storage::class, ['resource' => $application]) + ->set('isSwarm', true) + ->set('name', 'storage-app-data') + ->set('mount_path', '/data') + ->call('submitPersistentVolume') + ->assertHasNoErrors(); + + expect($application->persistentStorages()->first()) + ->name->toBe($application->uuid.'-storage-app-data') + ->host_path->toBeNull(); +}); + +it('uses a resource based default name for new volumes', function () { + [$application] = createApplicationWithVolume(['name' => 'Storage App']); + + Livewire::test(Storage::class, ['resource' => $application]) + ->assertSet('name', 'storage-app-data'); +}); + +it('uses a valid fallback default volume name when the resource name has no slug characters', function () { + [$application] = createApplicationWithVolume(['name' => '---']); + + Livewire::test(Storage::class, ['resource' => $application]) + ->assertSet('name', 'volume-data'); +}); + +it('removes existing bind mount source paths from the volume table', function () { + [$application, $volume] = createApplicationWithVolume(volumeAttributes: [ + 'host_path' => '/srv/storage', + ]); + + Livewire::test(All::class, ['resource' => $application]) + ->assertSet("forms.{$volume->id}.hostPath", '/srv/storage') + ->call('clearHostPath', $volume->id) + ->assertHasNoErrors(); + + expect($volume->refresh()->host_path)->toBeNull(); +}); + it('creates and exposes volume backups for service storage', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id,