diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 7161358ed0..da94470872 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -245,7 +245,6 @@ class Advanced extends Component return; } - $customInternalName = $this->customInternalName; $server = $this->application->destination->server; $allApplications = $server->applications(); @@ -253,9 +252,8 @@ class Advanced extends Component return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName; }); if ($foundSameInternalName->isNotEmpty()) { + $this->customInternalName = $this->application->settings->custom_internal_name; $this->dispatch('error', 'This custom container name is already in use by another application on this server.'); - $this->customInternalName = $customInternalName; - $this->syncData(true); return; } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 8a336b659b..531aa834c0 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application; use App\Enums\StaticImageTypes; use App\Jobs\ApplicationDeploymentJob; use App\Livewire\Project\Service\Storage; +use App\Livewire\Project\Shared\EnvironmentVariable\All; use App\Models\Application; use App\Rules\ValidGitBranch; use App\Services\Dns\ManagedDnsRecordCleanup; @@ -557,7 +558,7 @@ class General extends Component $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); $this->dispatch('storageCountsChanged')->to(Storage::class); - $this->dispatch('refreshEnvs'); + $this->dispatch('refreshEnvs')->to(All::class); } catch (\Throwable $e) { // Refresh model to get restored values from Application::loadComposeFile $this->application->refresh(); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index d25f875459..3ab0d3eda9 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Database\Redis; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; +use App\Livewire\Project\Shared\EnvironmentVariable\All; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; @@ -202,7 +203,7 @@ class General extends Component } catch (Exception $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshEnvs'); + $this->dispatch('refreshEnvs')->to(All::class); } } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 94a0d06c9c..fd968c7f0e 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Project\Shared\EnvironmentVariable\All; use App\Models\Service; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -179,7 +180,8 @@ class StackForm extends Component $this->service->refresh(); $this->service->saveComposeConfigs(); - $this->dispatch('refreshEnvs'); + $this->dispatch('refreshEnvs')->to(EditCompose::class); + $this->dispatch('refreshEnvs')->to(All::class); $this->dispatch('refreshServices'); $notify && $this->dispatch('success', 'Service saved.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 70cdef7f6b..6aee8fceba 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -93,7 +93,6 @@ class Show extends Component public array $problematicVariables = []; protected $listeners = [ - 'refreshEnvs' => 'refresh', 'refresh', 'compose_loaded' => '$refresh', ]; @@ -296,8 +295,8 @@ class Show extends Component } $this->serialize(); $this->env->save(); - $this->checkEnvs(); - $this->dispatch('refreshEnvs'); + $this->refresh(); + $this->dispatch('refreshEnvs')->to(All::class); } public function instantSave() diff --git a/resources/views/livewire/server/github-runner-executions.blade.php b/resources/views/livewire/server/github-runner-executions.blade.php index e89499303f..13db5599fe 100644 --- a/resources/views/livewire/server/github-runner-executions.blade.php +++ b/resources/views/livewire/server/github-runner-executions.blade.php @@ -40,10 +40,13 @@ @endif @if ($execution->isActive()) @can('update', $server) - - Cancel - + @endcan @endif diff --git a/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php new file mode 100644 index 0000000000..2835dc28f1 --- /dev/null +++ b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php @@ -0,0 +1,188 @@ + 0]); + config(['constants.ssh.mux_enabled' => false]); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +/** + * A `refreshEnvs` dispatch without a target reaches every environment variable row + * as well as the list, which races row removal ("Snapshot missing on Livewire component"). + */ +function assertNoPageWideRefreshEnvs(Testable $component): void +{ + $pageWide = collect(data_get($component->effects, 'dispatches')) + ->filter(fn (array $dispatch): bool => $dispatch['name'] === 'refreshEnvs' + && ! isset($dispatch['to']) + && empty($dispatch['self'])); + + expect($pageWide)->toBeEmpty(); +} + +test('reloading the compose file refreshes only the environment variable list', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'git_repository' => 'https://github.com/coollabsio/compose-app', + 'git_branch' => 'main', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + ]); + Process::fake(function ($process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + if (str_contains($command, 'git --version')) { + return Process::result(output: 'git version 2.43.0'); + } + if (str_contains($command, 'head -c')) { + return Process::result(output: "services:\n web:\n image: nginx:alpine\n"); + } + + return Process::result(output: ''); + }); + + $component = Livewire::test(ApplicationGeneral::class, ['application' => $this->application->fresh()]) + ->call('loadComposeFile') + ->assertDispatched('success', 'Docker compose file loaded.') + ->assertDispatchedTo(All::class, 'refreshEnvs'); + + assertNoPageWideRefreshEnvs($component); +}); + +test('saving redis credentials refreshes only the environment variable list', function () { + $database = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'old-password'])->fresh(); + + $component = Livewire::test(RedisGeneral::class, ['database' => $database]) + ->set('redisPassword', 'new-password') + ->call('submit') + ->assertDispatched('success', 'Database updated.') + ->assertDispatchedTo(All::class, 'refreshEnvs'); + + assertNoPageWideRefreshEnvs($component); + expect($database->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()->value) + ->toBe('new-password'); +}); + +test('saving a service refreshes the compose editor and the environment variable list only', function () { + Process::fake(); + $service = Service::factory()->create([ + 'name' => 'stack-service', + 'connect_to_docker_network' => false, + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $component = Livewire::test(StackForm::class, ['service' => $service]) + ->call('submit') + ->assertDispatched('success', 'Service saved.') + ->assertDispatchedTo(All::class, 'refreshEnvs') + ->assertDispatchedTo(EditCompose::class, 'refreshEnvs'); + + assertNoPageWideRefreshEnvs($component); +}); + +test('locking a variable updates the row itself and refreshes only the list', function () { + $env = EnvironmentVariable::create([ + 'key' => 'LOCK_ME', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('lock') + ->assertSet('isLocked', true) + ->assertSet('is_shown_once', true) + ->assertSet('valuesLoaded', false) + ->assertDispatchedTo(All::class, 'refreshEnvs'); + + assertNoPageWideRefreshEnvs($component); + expect($env->fresh()->is_shown_once)->toBeTruthy(); +}); + +test('environment variable rows do not handle the list-wide refreshEnvs event', function () { + $env = EnvironmentVariable::create([ + 'key' => 'ROW_KEY', + 'value' => 'value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $row = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']); + + expect(fn () => $row->dispatch('refreshEnvs'))->toThrow(EventHandlerDoesNotExist::class); +}); + +test('refreshing the list renders added rows with current data and drops deleted rows', function () { + $removed = EnvironmentVariable::create([ + 'key' => 'REMOVED_KEY', + 'value' => 'value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $this->application]) + ->call('loadEnvironmentVariables') + ->assertSee('REMOVED_KEY'); + + $removed->delete(); + EnvironmentVariable::create([ + 'key' => 'ADDED_KEY', + 'value' => 'value', + 'comment' => 'fresh comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $component->dispatch('refreshEnvs') + ->assertSee('ADDED_KEY') + ->assertSee('fresh comment') + ->assertDontSee('REMOVED_KEY'); +}); diff --git a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php index ac381762cb..97659c3210 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php @@ -148,3 +148,37 @@ it('rejects a container name prefix already used on the server', function () { expect($application->settings()->first()->custom_container_name_prefix)->toBeNull(); }); + +it('does not save a custom container name already used on the server', function () { + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_internal_name' => 'original-name']); + Application::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ])->settings->update(['custom_internal_name' => 'taken-name']); + + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customInternalName', 'taken-name') + ->call('saveCustomName') + ->assertDispatched('error', 'This custom container name is already in use by another application on this server.') + ->assertNotDispatched('success') + ->assertSet('customInternalName', 'original-name'); + + expect($application->settings()->first()->custom_internal_name)->toBe('original-name'); +}); + +it('saves a unique custom container name', function () { + $application = createApplicationForContainerNamingTest(); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customInternalName', 'Unique Name') + ->call('saveCustomName') + ->assertDispatched('success', 'Custom name saved.') + ->assertSet('customInternalName', 'unique-name'); + + expect($application->settings()->first()->custom_internal_name)->toBe('unique-name'); +}); diff --git a/tests/v4/Browser/EnvironmentVariableListRefreshTest.php b/tests/v4/Browser/EnvironmentVariableListRefreshTest.php new file mode 100644 index 0000000000..c1af6766d9 --- /dev/null +++ b/tests/v4/Browser/EnvironmentVariableListRefreshTest.php @@ -0,0 +1,78 @@ +set('app.maintenance.store', 'array'); + $this->stack = seedBrowserResourceStack(); + $this->application = createBrowserApplication($this->stack, [ + 'uuid' => 'app-browser-env-refresh', + 'name' => 'Env Refresh App', + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM nginx:alpine\n", + ]); + + foreach (['ALPHA_KEY', 'BRAVO_KEY', 'CHARLIE_KEY', 'DELTA_KEY'] as $key) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => strtolower($key), + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + } +}); + +it('locks and deletes environment variables repeatedly without Livewire errors', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ).'/environment-variables'; + + $page = visit($url); + $page->click('Accept and close') + ->assertSee('ALPHA_KEY') + ->assertSee('DELTA_KEY'); + + // Collect uncaught errors (e.g. "Snapshot missing on Livewire component") raised by the actions below. + $page->script(<<<'JS' + (() => { + window.__envListErrors = []; + window.addEventListener('error', (event) => window.__envListErrors.push(String(event.message))); + window.addEventListener('unhandledrejection', (event) => window.__envListErrors.push(String(event.reason))); + const consoleError = console.error; + console.error = (...args) => { + window.__envListErrors.push(args.map(String).join(' ')); + consoleError(...args); + }; + })() + JS); + + $page->click('button[title="ALPHA_KEY"]') + ->click('[data-environment-variable-update-actions] button:has-text("Lock"):visible') + ->wait(1); + $page->script('window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))'); + $page->wait(0.5); + + foreach (['BRAVO_KEY', 'CHARLIE_KEY', 'DELTA_KEY'] as $key) { + $page->click("button[title=\"{$key}\"]") + ->click('[data-environment-variable-delete-action] button:visible') + ->fill('[x-model="userConfirmationText"]:visible', $key) + ->click('button:has([x-text="step2ButtonText"]):visible') + ->assertSee('Environment variable deleted successfully.') + ->assertDontSee($key); + } + + $page->assertSee('ALPHA_KEY') + ->screenshot(filename: 'environment-variable-list-refresh'); + + expect($page->script('window.__envListErrors'))->toBe([]) + ->and($this->application->environment_variables()->pluck('key')->all())->toBe(['ALPHA_KEY']) + ->and($this->application->environment_variables()->where('key', 'ALPHA_KEY')->value('is_shown_once'))->toBeTruthy(); +});