@@ -50,7 +53,6 @@
diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php
index 81a1bce5d..92b50967a 100644
--- a/resources/views/livewire/upgrade.blade.php
+++ b/resources/views/livewire/upgrade.blade.php
@@ -181,6 +181,7 @@
currentVersion: config.currentVersion || '',
latestVersion: config.latestVersion || '',
serviceDown: false,
+ instanceWentDown: false,
devMode: config.devMode || false,
simulationInterval: null,
@@ -272,6 +273,60 @@
}) >= 0;
},
+ isReadyToReload(runningVersion) {
+ if (this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
+ return true;
+ }
+
+ // Releases before this header existed (e.g. 4.3.1) still
+ // return a healthy /api/health with no X-Coolify-Version.
+ // Only treat that as done after the instance actually went down.
+ return !runningVersion && this.instanceWentDown;
+ },
+
+ startHealthWatch() {
+ if (this.checkHealthInterval) {
+ return;
+ }
+ this.checkHealthInterval = setInterval(() => {
+ this.probeHealth();
+ }, 2000);
+ },
+
+ probeHealth() {
+ this.healthCheckAttempts++;
+ const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
+
+ return fetch('/api/health')
+ .then(response => {
+ const runningVersion = response.headers.get('X-Coolify-Version');
+ if (!response.ok) {
+ this.instanceWentDown = true;
+ this.currentStep = 4;
+ this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
+ return;
+ }
+ if (this.isReadyToReload(runningVersion)) {
+ this.showSuccess();
+ return;
+ }
+ if (!this.instanceWentDown && this.currentStep < 4) {
+ return;
+ }
+ if (runningVersion) {
+ this.currentStatus = `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`;
+ } else {
+ this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
+ }
+ })
+ .catch(error => {
+ console.error('Health check failed:', error);
+ this.instanceWentDown = true;
+ this.currentStep = 4;
+ this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
+ });
+ },
+
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
@@ -287,31 +342,9 @@
},
revive() {
- if (this.checkHealthInterval) return true;
- this.healthCheckAttempts = 0;
this.currentStep = 4;
console.log('Checking server\'s health...');
- this.checkHealthInterval = setInterval(() => {
- this.healthCheckAttempts++;
- const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
- fetch('/api/health')
- .then(response => {
- const runningVersion = response.headers.get('X-Coolify-Version');
- if (response.ok && this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
- this.showSuccess();
- } else if (response.ok) {
- this.currentStatus = runningVersion
- ? `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`
- : this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
- } else {
- this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
- }
- })
- .catch(error => {
- console.error('Health check failed:', error);
- this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
- });
- }, 2000);
+ this.startHealthWatch();
},
showSuccess() {
@@ -388,7 +421,9 @@
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.serviceDown = false;
+ this.instanceWentDown = false;
this.livewireFailures = 0;
+ this.startHealthWatch();
// Poll upgrade status via Livewire
this.checkUpgradeStatusInterval = setInterval(async () => {
@@ -399,25 +434,30 @@
this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message;
} else if (data.status === 'complete') {
- if (this.hasReachedTargetVersion(data.running_version || this.currentVersion, this.latestVersion)) {
+ if (this.isReadyToReload(data.running_version)) {
this.showSuccess();
} else {
this.currentStep = 4;
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
+ this.revive();
}
} else if (data.status === 'error') {
this.showError(data.message);
+ } else if (data.status === 'none' && this.instanceWentDown) {
+ this.revive();
+ await this.probeHealth();
}
} catch (error) {
this.livewireFailures++;
if (this.livewireFailures < 3) {
- this.currentStatus = 'Lost contact with Coolify, retrying...';
+ this.currentStatus = 'Reconnecting. This is expected during an upgrade...';
return;
}
// Repeated Livewire failures usually mean the instance is restarting
console.log('Livewire unavailable, switching to health check mode');
if (!this.serviceDown) {
this.serviceDown = true;
+ this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = 'Coolify is restarting with the new version...';
if (this.checkUpgradeStatusInterval) {
diff --git a/routes/web.php b/routes/web.php
index 1d4e80d06..5561ba0ec 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -118,8 +118,12 @@ Route::middleware(['throttle:login'])->group(function () {
Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect');
Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback');
-// Local-only previews for redesigned HTTP error pages (never registered in production).
-if (app()->environment('local')) {
+// Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production).
+if (app()->environment(['local', 'testing'])) {
+ Route::get('/__exception', function () {
+ throw new RuntimeException('Testing Laravel exception page');
+ })->name('dev.exception-preview');
+
Route::get('/__error/{code}', function (string $code) {
$allowed = ['400', '401', '402', '403', '404', '419', '429', '500', '503'];
abort_unless(in_array($code, $allowed, true), 404);
diff --git a/tests/Feature/AdvancedMenuIconConsistencyTest.php b/tests/Feature/AdvancedMenuIconConsistencyTest.php
index 2fac60df0..f54e4b060 100644
--- a/tests/Feature/AdvancedMenuIconConsistencyTest.php
+++ b/tests/Feature/AdvancedMenuIconConsistencyTest.php
@@ -37,6 +37,7 @@ test('advanced action dropdown menus use the grid icon', function () {
$files = [
resource_path('views/components/applications/advanced.blade.php'),
resource_path('views/components/services/advanced.blade.php'),
+ resource_path('views/components/server/advanced.blade.php'),
];
foreach ($files as $path) {
diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php
index 188d3603b..793f76b1f 100644
--- a/tests/Feature/Authorization/NotificationAuthorizationTest.php
+++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php
@@ -206,6 +206,38 @@ test('admin can update email notification settings', function () {
expect($this->admin->can('update', $settings))->toBeTrue();
});
+test('admin can save team smtp settings without a resend api key when resend is disabled', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(EmailNotification::class)
+ ->set('smtpFromAddress', 'alerts@example.com')
+ ->set('smtpFromName', 'Coolify')
+ ->set('smtpHost', 'smtp.example.com')
+ ->set('smtpPort', '587')
+ ->set('smtpEncryption', 'starttls')
+ ->set('resendEnabled', false)
+ ->set('resendApiKey', null)
+ ->call('submitResend')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+});
+
+test('admin cannot enable team resend without an api key', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(EmailNotification::class)
+ ->set('smtpFromAddress', 'alerts@example.com')
+ ->set('smtpFromName', 'Coolify')
+ ->set('resendEnabled', true)
+ ->set('resendApiKey', null)
+ ->call('submitResend')
+ ->assertDispatched('error');
+
+ expect($this->team->emailNotificationSettings()->first()->resend_enabled)->toBeFalse();
+});
+
// --- Pushover ---
test('member cannot send test notification on pushover', function () {
diff --git a/tests/Feature/DeploymentShowMissingEnvironmentTest.php b/tests/Feature/DeploymentShowMissingEnvironmentTest.php
new file mode 100644
index 000000000..92be923d9
--- /dev/null
+++ b/tests/Feature/DeploymentShowMissingEnvironmentTest.php
@@ -0,0 +1,79 @@
+ 'file',
+ 'cache.default' => 'array',
+ ]);
+
+ $this->user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ InstanceSettings::unguarded(function () {
+ InstanceSettings::query()->create([
+ 'id' => 0,
+ 'is_registration_enabled' => true,
+ ]);
+ });
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'status' => 'running',
+ ]);
+});
+
+it('returns 404 when the deployment environment does not exist', function () {
+ $this->get(route('project.application.deployment.show', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
+ 'application_uuid' => $this->application->uuid,
+ 'deployment_uuid' => 'qn5yfvo8clppyt44b75k7dav',
+ ]))->assertNotFound();
+});
+
+it('returns 404 when the deployment index environment does not exist', function () {
+ $this->get(route('project.application.deployment.index', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
+ 'application_uuid' => $this->application->uuid,
+ ]))->assertNotFound();
+});
+
+it('returns 404 when the database backup index environment does not exist', function () {
+ $this->get(route('project.database.backup.index', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
+ 'database_uuid' => 'missing-database',
+ ]))->assertNotFound();
+});
+
+it('returns 404 when the database backup execution environment does not exist', function () {
+ $this->get(route('project.database.backup.execution', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
+ 'database_uuid' => 'missing-database',
+ 'backup_uuid' => 'missing-backup',
+ ]))->assertNotFound();
+});
diff --git a/tests/Feature/ExceptionRendererTest.php b/tests/Feature/ExceptionRendererTest.php
new file mode 100644
index 000000000..0f8e6739e
--- /dev/null
+++ b/tests/Feature/ExceptionRendererTest.php
@@ -0,0 +1,39 @@
+ 0]);
+});
+
+test('debug exceptions use laravel renderer instead of ignition', function () {
+ config(['app.debug' => true]);
+
+ expect(class_exists('Spatie\\LaravelIgnition\\IgnitionServiceProvider'))->toBeFalse()
+ ->and(app()->bound(ExceptionRenderer::class))->toBeFalse()
+ ->and(app()->bound(Renderer::class))->toBeTrue();
+
+ $response = app(ExceptionHandler::class)
+ ->render(request(), new RuntimeException('default-laravel-exception-renderer'));
+
+ expect($response->getContent())
+ ->toContain('default-laravel-exception-renderer')
+ ->toContain('scheme-light-dark')
+ ->toContain('dark:bg-neutral-900');
+});
+
+test('dev exception preview url renders the laravel debug page', function () {
+ config(['app.debug' => true]);
+
+ $this->get('/__exception')
+ ->assertServerError()
+ ->assertSee('RuntimeException', false)
+ ->assertSee('Testing Laravel exception page')
+ ->assertSee('scheme-light-dark', false);
+});
diff --git a/tests/Feature/NotificationEmailResendLayoutTest.php b/tests/Feature/NotificationEmailResendLayoutTest.php
new file mode 100644
index 000000000..6d37287d9
--- /dev/null
+++ b/tests/Feature/NotificationEmailResendLayoutTest.php
@@ -0,0 +1,25 @@
+/s', $instance, $instanceResend);
+ preg_match('/settings-section title="Resend".*?<\/x-application.settings-section>/s', $team, $teamResend);
+
+ expect($instanceResend[0] ?? '')
+ ->not->toBeEmpty()
+ ->toContain('grid gap-4 lg:grid-cols-2')
+ ->toContain('id="resendEnabled"')
+ ->toContain('id="resendApiKey"')
+ ->not->toContain('lg:col-span-2')
+ ->not->toContain('description=');
+
+ expect($teamResend[0] ?? '')
+ ->not->toBeEmpty()
+ ->toContain('grid gap-4 lg:grid-cols-2')
+ ->toContain('id="resendEnabled"')
+ ->toContain('id="resendApiKey"')
+ ->not->toContain('lg:col-span-2')
+ ->not->toContain('description=');
+});
diff --git a/tests/Feature/ResourceHeadingOverflowTest.php b/tests/Feature/ResourceHeadingOverflowTest.php
new file mode 100644
index 000000000..e8f2b61e6
--- /dev/null
+++ b/tests/Feature/ResourceHeadingOverflowTest.php
@@ -0,0 +1,36 @@
+toContain('data-resource-heading-overflow')
+ ->toContain('measureInlineWidth')
+ ->toContain('availableWidth')
+ ->toContain('reserved += 200')
+ ->toContain('hudSiblings')
+ ->toContain('Actions')
+ ->toContain('listbox-panel top-full! right-0! left-auto!')
+ ->toContain("\$dispatch('resource-actions-toggled', { open })");
+
+ expect($css)
+ ->toContain('.resource-heading-overflow-items')
+ ->toContain('.resource-heading-overflow-items.is-measuring')
+ ->toContain('.resource-heading-overflow.is-collapsed');
+});
+
+it('uses the overflow group for application, service, database, and server desktop actions', function () {
+ $files = [
+ resource_path('views/livewire/project/application/heading.blade.php') => 'application-desktop-actions',
+ resource_path('views/livewire/project/service/heading.blade.php') => 'service-desktop-actions',
+ resource_path('views/livewire/project/database/heading.blade.php') => 'database-desktop-actions',
+ resource_path('views/livewire/server/navbar.blade.php') => 'server-desktop-actions',
+ ];
+
+ foreach ($files as $path => $id) {
+ expect(file_get_contents($path))
+ ->toContain('
toContain($id);
+ }
+});
diff --git a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
index 0462040f4..c4f150518 100644
--- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
+++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
@@ -113,24 +113,47 @@ it('places the account menu beside the desktop sidebar toggle while retaining it
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
});
-it('keeps advanced operations in a separated section at the bottom of actions menus', function () {
+it('keeps advanced operations in a dedicated Advanced dropdown', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
+ $applicationAdvanced = file_get_contents(resource_path('views/components/applications/advanced.blade.php'));
+ $serviceAdvanced = file_get_contents(resource_path('views/components/services/advanced.blade.php'));
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
expect($applicationDesktop)
- ->not->toContain('toContain('toContain('application-desktop-actions')
- ->toContain('role="separator"')
- ->toContain('Force deploy without cache')
+ ->not->toContain('not->toContain('resource-heading-overflow-separator')
+ ->not->toContain('Force deploy without cache')
->and($serviceDesktop)
- ->not->toContain('toContain('toContain('toContain('service-desktop-actions')
- ->toContain('role="separator"')
- ->toContain('Pull Latest Images & Restart')
+ ->not->toContain('resource-heading-overflow-separator')
+ ->not->toContain('Pull Latest Images & Restart');
+
+ expect(strpos($applicationDesktop, 'toBeLessThan(strpos($applicationDesktop, 'toBeLessThan(strpos($applicationDesktop, 'application-desktop-actions'));
+
+ expect(strpos($serviceDesktop, 'toBeLessThan(strpos($serviceDesktop, 'toBeLessThan(strpos($serviceDesktop, 'toBeLessThan(strpos($serviceDesktop, 'service-desktop-actions'));
+
+ expect($applicationAdvanced)
+ ->toContain('Advanced')
+ ->toContain('name="grid"')
+ ->not->toContain('Force deploy without cache')
+ ->and($serviceAdvanced)
+ ->toContain('Advanced')
+ ->toContain('name="grid"')
+ ->not->toContain('Pull Latest Images & Restart')
+ ->not->toContain('Pull latest and restart')
->toContain('Force Restart')
->toContain('Force Deploy')
->toContain('Force Cleanup Containers')
@@ -142,29 +165,36 @@ it('keeps advanced operations in a separated section at the bottom of actions me
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
});
-it('groups application lifecycle controls in an actions dropdown', function () {
+it('lists desktop application lifecycle controls inline and collapses them when space runs out', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+ $overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain('application-desktop-actions')
- ->toContain('Actions')
- ->toContain('listbox-panel top-full! right-0! left-auto!')
- ->toContain('Redeploy')
+ ->toContain('toContain('toContain('Restart')
->toContain('Stop')
- ->toContain('Deploy');
+ ->not->toContain('listbox-option');
+
+ expect($overflow)
+ ->toContain('Actions')
+ ->toContain('listbox-panel top-full! right-0! left-auto!')
+ ->toContain('measureInlineWidth')
+ ->toContain('availableWidth');
});
it('raises the desktop top bar while any resource dropdown is open', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$dropdowns = [
- resource_path('views/livewire/project/application/heading.blade.php'),
- resource_path('views/livewire/project/database/heading.blade.php'),
- resource_path('views/livewire/project/service/heading.blade.php'),
- resource_path('views/livewire/server/navbar.blade.php'),
+ resource_path('views/components/resource-heading-overflow.blade.php'),
resource_path('views/components/applications/links.blade.php'),
resource_path('views/components/services/links.blade.php'),
+ resource_path('views/components/applications/deploy.blade.php'),
+ resource_path('views/components/services/advanced.blade.php'),
+ resource_path('views/components/services/restart.blade.php'),
+ resource_path('views/components/server/advanced.blade.php'),
];
expect($layout)
@@ -178,15 +208,48 @@ it('raises the desktop top bar while any resource dropdown is open', function ()
}
});
-it('keeps deploy in the actions menu alongside advanced operations', function () {
- $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+it('groups service restart options in a Restart dropdown', function () {
+ $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
+ $restart = file_get_contents(resource_path('views/components/services/restart.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
- ->toContain("@if (str(\$application->status)->startsWith('exited'))")
+ ->toContain('not->toContain('Pull Latest Images & Restart')
+ ->and($restart)
+ ->toContain('Restart current version')
+ ->toContain('Pull latest and restart')
+ ->toContain("\$wire.dispatch('pullAndRestartEvent')")
+ ->toContain('service-restart-trigger');
+
+ $mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
+
+ expect($mobile)
+ ->toContain('Restart current version')
+ ->toContain('Pull latest and restart')
+ ->not->toContain('Pull Latest Images & Restart');
+});
+
+it('groups application deploy options in a Deploy dropdown', function () {
+ $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+ $deploy = file_get_contents(resource_path('views/components/applications/deploy.blade.php'));
+ $desktop = str($heading)->after('resource-heading-actions flex')->toString();
+
+ expect($desktop)
+ ->toContain('toContain('id="application-desktop-actions"')
+ ->not->toContain('Force deploy without cache')
+ ->and($deploy)
->toContain('Deploy')
- ->toContain('Force deploy without cache');
+ ->toContain('Deploy (without cache)')
+ ->toContain('force_deploy_without_cache')
+ ->toContain('deploy(true)');
+
+ $mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
+
+ expect($mobile)
+ ->toContain('Deploy (without cache)')
+ ->not->toContain('Force deploy without cache');
});
it('renders configuration warnings as navbar popovers instead of floating notifications', function () {
diff --git a/tests/Feature/ServerNavbarStatusLayoutTest.php b/tests/Feature/ServerNavbarStatusLayoutTest.php
index 11b5b0b80..e05a4359e 100644
--- a/tests/Feature/ServerNavbarStatusLayoutTest.php
+++ b/tests/Feature/ServerNavbarStatusLayoutTest.php
@@ -42,19 +42,34 @@ it('uses the branded input focus state for the server filter', function () {
->not->toContain('after('id="server-desktop-actions"')->before('@endteleport')->toString();
+ $overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
+ $advanced = file_get_contents(resource_path('views/components/server/advanced.blade.php'));
+ $desktopActions = str($navbar)->after("@teleport('#resource-action-hud-slot')")->before('@endteleport')->toString();
expect($desktopActions)
- ->toContain('Actions')
- ->toContain('Traefik Dashboard')
- ->toContain('name="external-link" class="size-3! opacity-70"')
- ->toContain('class="flex size-4 shrink-0 items-center justify-center"')
->toContain('Restart Proxy')
->toContain('Stop Proxy')
->toContain('Start Proxy')
- ->toContain('Refresh Proxy Status')
- ->toContain('listbox-panel')
+ ->toContain('not->toContain('Traefik Dashboard')
+ ->not->toContain('Refresh Proxy Status')
+ ->not->toContain('resource-heading-overflow-separator')
->not->toContain('toBeLessThan(strpos($desktopActions, 'id="server-desktop-actions"'));
+
+ expect($advanced)
+ ->toContain('Advanced')
+ ->toContain('name="grid"')
+ ->toContain('Traefik Dashboard')
+ ->toContain('name="external-link" class="size-3! opacity-70"')
+ ->toContain('class="flex size-4 shrink-0 items-center justify-center"')
+ ->toContain('Refresh Proxy Status');
+
+ expect($overflow)
+ ->toContain('Actions')
+ ->toContain('listbox-panel');
});
diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
index c191c0f25..f2b5758db 100644
--- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
+++ b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
@@ -30,10 +30,9 @@ it('matches application action bar behavior for services and databases', functio
foreach ([$service, $database] as $heading) {
expect($heading)
- ->toContain('xl:fixed xl:top-14 xl:right-4')
+ ->toContain('@teleport(\'#resource-action-hud-slot\')')
->toContain('xl:w-auto')
- ->toContain('Actions')
- ->toContain('listbox-panel top-full! right-0! left-auto!')
+ ->toContain('not->toContain('hidden lg:block lg:h-12');
}
diff --git a/tests/Feature/SettingsEmailSmtpSetupTest.php b/tests/Feature/SettingsEmailSmtpSetupTest.php
new file mode 100644
index 000000000..654b45453
--- /dev/null
+++ b/tests/Feature/SettingsEmailSmtpSetupTest.php
@@ -0,0 +1,137 @@
+create(['id' => 0]);
+ InstanceSettings::forceCreate([
+ 'id' => 0,
+ 'smtp_enabled' => false,
+ 'resend_enabled' => false,
+ ]);
+ Once::flush();
+
+ $user = User::factory()->create();
+ $rootTeam->members()->attach($user->id, ['role' => 'admin']);
+
+ return $user;
+}
+
+function smtpSetupPayload(): array
+{
+ return [
+ 'smtpFromAddress' => 'alerts@example.com',
+ 'smtpFromName' => 'Coolify',
+ 'smtpHost' => 'smtp.example.com',
+ 'smtpPort' => '587',
+ 'smtpEncryption' => 'starttls',
+ 'resendEnabled' => false,
+ 'resendApiKey' => null,
+ ];
+}
+
+test('saving smtp settings does not require a resend api key when resend is disabled', function () {
+ $user = setupInstanceAdminForEmailSettings();
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => 0]]);
+
+ Livewire::test(SettingsEmail::class)
+ ->fill(smtpSetupPayload())
+ ->set('smtpEnabled', true)
+ ->call('submitSmtp')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+
+ $settings = InstanceSettings::find(0);
+
+ expect($settings->smtp_enabled)->toBeTrue()
+ ->and($settings->smtp_host)->toBe('smtp.example.com')
+ ->and($settings->resend_enabled)->toBeFalse();
+});
+
+test('saving transactional email settings does not require a resend api key when resend is disabled', function () {
+ $user = setupInstanceAdminForEmailSettings();
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => 0]]);
+
+ Livewire::test(SettingsEmail::class)
+ ->fill(smtpSetupPayload())
+ ->call('submit')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+
+ $settings = InstanceSettings::find(0);
+
+ expect($settings->smtp_host)->toBe('smtp.example.com')
+ ->and($settings->resend_enabled)->toBeFalse()
+ ->and($settings->resend_api_key)->toBeNull();
+});
+
+test('enabling smtp delivery does not require a resend api key when resend is disabled', function () {
+ $user = setupInstanceAdminForEmailSettings();
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => 0]]);
+
+ Livewire::test(SettingsEmail::class)
+ ->fill(smtpSetupPayload())
+ ->set('smtpEnabled', true)
+ ->call('instantSaveSmtp')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+
+ $settings = InstanceSettings::find(0);
+
+ expect($settings->smtp_enabled)->toBeTrue()
+ ->and($settings->resend_enabled)->toBeFalse();
+});
+
+test('disabling resend does not require a resend api key', function () {
+ $user = setupInstanceAdminForEmailSettings();
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => 0]]);
+
+ Livewire::test(SettingsEmail::class)
+ ->fill(smtpSetupPayload())
+ ->set('resendEnabled', false)
+ ->set('resendApiKey', null)
+ ->call('submitResend')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+});
+
+test('enabling resend still requires an api key', function () {
+ $user = setupInstanceAdminForEmailSettings();
+
+ $this->actingAs($user);
+ session(['currentTeam' => ['id' => 0]]);
+
+ Livewire::test(SettingsEmail::class)
+ ->fill(smtpSetupPayload())
+ ->set('resendEnabled', true)
+ ->set('resendApiKey', null)
+ ->call('submitResend')
+ ->assertDispatched('error');
+
+ expect(InstanceSettings::find(0)->resend_enabled)->toBeFalse();
+});
+
+test('email settings page has a single unsaved bar so smtp save cannot hit resend validation', function () {
+ $view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
+
+ expect(substr_count($view, 'toBe(1)
+ ->and($view)->toContain('action="submit"')
+ ->and($view)->not->toContain('action="submitResend"');
+});
diff --git a/tests/Feature/UpgradeComponentTest.php b/tests/Feature/UpgradeComponentTest.php
index 0d869d3be..dc14516a5 100644
--- a/tests/Feature/UpgradeComponentTest.php
+++ b/tests/Feature/UpgradeComponentTest.php
@@ -34,6 +34,36 @@ it('initializes latest version during mount from cached versions data', function
->assertSee('4.0.0-beta.999');
});
+it('does not highlight the current upgrade stage with the warning yellow accent', function () {
+ $progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
+
+ expect($progressView)
+ ->toContain('bg-neutral-100 text-neutral-900 dark:bg-white/[0.08] dark:text-fg')
+ ->not->toContain('dark:text-warning')
+ ->not->toContain('dark:bg-warning');
+});
+
+it('uses a current-color spinner on upgrade stages instead of the brand purple loader', function () {
+ $progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
+ $appCss = file_get_contents(resource_path('css/app.css'));
+
+ expect($progressView)
+ ->toContain('spinner-current')
+ ->not->toContain('loading-indicator')
+ ->and($appCss)
+ ->toContain('.dark .animate-spin.spinner-current')
+ ->toContain('html[data-theme="custom"] .animate-spin.spinner-current')
+ ->toContain('color: inherit !important;');
+});
+
+it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure', function () {
+ $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
+
+ expect($upgradeView)
+ ->toContain('Reconnecting. This is expected during an upgrade...')
+ ->not->toContain('Lost contact with Coolify');
+});
+
it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
$utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
diff --git a/tests/Unit/CoolifyUpgradeStatusTest.php b/tests/Unit/CoolifyUpgradeStatusTest.php
index 7d45b0445..68fa2ee94 100644
--- a/tests/Unit/CoolifyUpgradeStatusTest.php
+++ b/tests/Unit/CoolifyUpgradeStatusTest.php
@@ -128,6 +128,16 @@ it('waits for the running version to match the target before showing reload', fu
->toContain('livewireFailures');
});
+it('treats a healthy instance without a version header as ready only after downtime', function () {
+ $upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php');
+
+ expect($upgradeView)
+ ->toContain('instanceWentDown')
+ ->toContain('isReadyToReload')
+ ->toContain('startHealthWatch')
+ ->toContain('data.status === \'none\'');
+});
+
it('starts the upgrade after the Livewire response so status polling is not blocked', function () {
$upgradeComponent = file_get_contents(__DIR__.'/../../app/Livewire/Upgrade.php');