feat(ui): polish domains, storage, env vars and resource nav

Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests.
This commit is contained in:
Andras Bacsai committed 2026-08-05 13:50:11 +02:00
1 parent dbc6f5e08c
commit a7a06aa6c8
75 files changed
+3909 -1173

No files matched your search

+145 -18
View File
@@ -189,7 +189,8 @@ it('adds a domain to the application', function () {
$this->application->refresh();
expect($this->application->fqdn)->toBe('https://app.example.com');
expect(explode(',', (string) $this->application->fqdn))
->toBe(['https://app.example.com', 'https://www.app.example.com']);
});
it('adds multiple domains without replacing existing ones', function () {
@@ -218,7 +219,8 @@ it('blocks adding a domain with bad dns until the user continues', function () {
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
->call('addDomain')
->assertSet('addDomainDnsFailed', true)
->assertSee('DNS validation failed');
->assertSee('DNS is not pointing to the right IP')
->assertSee('Are you sure you want to add it anyway');
$this->application->refresh();
expect($this->application->fqdn)->toBeNull();
@@ -229,7 +231,10 @@ it('blocks adding a domain with bad dns until the user continues', function () {
->assertDispatched('close-modal');
$this->application->refresh();
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid');
expect(explode(',', (string) $this->application->fqdn))->toBe([
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
]);
});
it('resets the dns gate when the domain input changes', function () {
@@ -281,7 +286,8 @@ it('blocks editing a domain with bad dns until the user continues', function ()
->call('updateDomain')
->assertSet('editDomainDnsFailed', true)
->assertSet('showEditDomainModal', true)
->assertSee('DNS validation failed');
->assertSee('DNS is not pointing to the right IP')
->assertSee('Are you sure you want to save it anyway');
$this->application->refresh();
expect($this->application->fqdn)->toBe('https://old.example.com');
@@ -367,6 +373,101 @@ it('auto-adds missing www counterpart as a normal domain when setting www redire
->toContain('https://www.example.com');
});
it('does not re-add a removed www counterpart on page load when redirect is www', function () {
$this->application->update([
'fqdn' => 'https://asd.hu',
'redirect' => 'www',
]);
// Mount alone must not re-add missing pairs (would undo deletes).
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://asd.hu');
expect(explode(',', (string) $this->application->fresh()->fqdn))
->toContain('https://asd.hu')
->not->toContain('https://www.asd.hu');
});
it('keeps a domain removed when its www counterpart remains and redirect is www', function () {
$this->application->update([
'fqdn' => 'https://asd.hu,https://www.asd.hu',
'redirect' => 'www',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('removeDomain', 0)
->assertDispatched('success');
$this->application->refresh();
expect(explode(',', (string) $this->application->fqdn))
->toContain('https://www.asd.hu')
->not->toContain('https://asd.hu');
});
it('auto-adds www pair when adding a domain while redirect is www', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = false;
$settings->save();
$this->application->update([
'fqdn' => null,
'redirect' => 'www',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
$this->application->refresh();
expect(explode(',', (string) $this->application->fqdn))
->toContain('https://app.example.com')
->toContain('https://www.app.example.com');
});
it('auto-adds the suggested www pair when adding a domain with both directions', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = false;
$settings->save();
$this->application->update([
'fqdn' => null,
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->application->fresh()->fqdn))
->toContain('https://app.example.com')
->toContain('https://www.app.example.com');
});
it('appends a generated domain without replacing existing domains', function () {
$this->application->update([
'fqdn' => 'https://existing.example.com,https://www.existing.example.com',
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('generateDomain')
->assertDispatched('success')
->assertNotDispatched('error');
$domains = explode(',', (string) $this->application->fresh()->fqdn);
expect($domains)
->toContain('https://existing.example.com')
->toContain('https://www.existing.example.com')
->toHaveCount(3);
});
it('auto-adds missing non-www counterpart as a normal domain when setting non-www redirect', function () {
$this->application->update([
'fqdn' => 'https://www.example.com',
@@ -479,7 +580,25 @@ it('loads persisted dns status on page load', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.0.dns_message', 'DNS does not point to 203.0.113.10.')
->assertSee('DNS does not point to 203.0.113.10.');
->assertSee('DNS mismatch')
->assertDontSee('DNS does not point to 203.0.113.10.')
->call('openDnsRecordsModal')
->assertSet('showDnsRecordsModal', true);
});
it('shows dns mismatches before other domain entries', function () {
$this->application->update([
'fqdn' => 'https://healthy.example.com,https://broken.example.com',
'domain_dns_statuses' => [
'https://healthy.example.com' => ['status' => 'ok', 'message' => 'OK'],
'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
],
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://broken.example.com')
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.1.url', 'https://healthy.example.com');
});
it('hides dns message text when dns status is ok', function () {
@@ -550,7 +669,7 @@ it('resolves hostname server addresses to a real ip for dns messages', function
$message = $component->get('domainRows.0.dns_message');
$recordType = dnsRecordTypeForIp($resolvedIp);
// Failed checks show short "A record → ip" guidance; ok checks mention the hostname label.
// Failed checks show required DNS record guidance; ok checks mention the hostname label.
if ($component->get('domainRows.0.dns_status') === 'failed') {
expect($message)->toBe("{$recordType} record → {$resolvedIp}")
->and($message)->not->toContain('CNAME');
@@ -574,7 +693,7 @@ it('uses short aaaa guidance when the server ip is ipv6', function () {
->call('checkDomainDns', 0);
expect($component->get('serverIp'))->toBe('2001:db8::10')
->and($component->get('domainRows.0.dns_message'))->toBe('AAAA record → 2001:db8::10');
->and($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type AAAA pointing to 2001:db8::10');
});
it('uses short a-record guidance for compose applications', function () {
@@ -596,7 +715,7 @@ it('uses short a-record guidance for compose applications', function () {
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkDomainDns', 0);
expect($component->get('domainRows.0.dns_message'))->toBe('A record → 172.16.0.3')
expect($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type A pointing to 172.16.0.3')
->and($component->get('domainRows.0.dns_status'))->toBe('failed');
});
@@ -608,7 +727,10 @@ it('normalizes domains before saving', function () {
$this->application->refresh();
expect($this->application->fqdn)->toBe(ValidationPatterns::normalizeApplicationDomains('HTTPS://App.Example.COM/Path'));
expect(explode(',', (string) $this->application->fqdn))->toBe([
ValidationPatterns::normalizeApplicationDomains('HTTPS://App.Example.COM/Path'),
'https://www.app.example.com/Path',
]);
});
it('shows the missing www counterpart as a suggested domain row', function () {
@@ -622,23 +744,28 @@ it('shows the missing www counterpart as a suggested domain row', function () {
->assertSet('domainRows.0.is_suggested', false)
->assertSet('domainRows.1.url', 'https://www.example.com')
->assertSet('domainRows.1.is_suggested', true)
->assertSee('Suggested www')
->assertSee('Add')
->assertSet('domainRows.1.suggestion_label', null)
->assertSet('domainRows.1.dns_message', 'Not configured yet.')
->assertSee('Add domain')
->assertSee('Not configured yet.')
->assertDontSee('Not added ·')
->assertDontSee('click Add domain')
->assertDontSee('does not add this automatically')
->assertSee('https://www.example.com');
});
it('does not change suggested domain labels or persist until Set Direction saves', function () {
it('does not change suggested domain role or persist until Set Direction saves', function () {
$this->application->update([
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.1.suggestion_label', 'Suggested www')
->assertSet('domainRows.1.suggestion_label', null)
->assertSet('domainRows.1.suggestion_role', 'pair')
->set('redirect', 'www')
// Dropdown alone must not rebuild suggestions or persist redirect.
->assertSet('domainRows.1.suggestion_label', 'Suggested www')
->assertSet('domainRows.1.suggestion_label', null)
->assertSet('domainRows.1.suggestion_role', 'pair');
expect($this->application->fresh()->redirect)->toBe('both');
@@ -1138,20 +1265,20 @@ it('auto-adds missing www pair for a single compose service redirect', function
->and($webDomains)->toContain('https://www.web.example.com');
});
it('uses compose service redirect for suggested domain messaging', function () {
it('uses compose service redirect for suggested domain messaging when direction is both', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'fqdn' => null,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'www'],
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'],
]),
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('isCompose', true)
->set('composeServices', ['web'])
->set('serviceRedirects.web', 'www');
->set('serviceRedirects.web', 'both');
$component->instance()->domainRows = (function () use ($component) {
$method = new ReflectionMethod($component->instance(), 'buildDomainRows');
@@ -1162,6 +1289,6 @@ it('uses compose service redirect for suggested domain messaging', function () {
$suggested = collect($component->get('domainRows'))->firstWhere('is_suggested', true);
expect($suggested)->not->toBeNull()
->and($suggested['suggestion_role'] ?? null)->toBe('canonical')
->and($suggested['suggestion_role'] ?? null)->toBe('pair')
->and($suggested['url'] ?? null)->toBe('https://www.web.example.com');
});
@@ -0,0 +1,173 @@
<?php
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->routeParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
];
});
/**
* Settings tab must carry both the active class and aria-current so CSS
* under .application-heading-actions can override the base tab resets.
*/
function assertSettingsTabActive(string $html): void
{
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(1);
// Desktop navbar CSS override must exist so active styles are visible.
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain(".application-heading-actions .app-tab[aria-current='page']")
->toContain('.application-heading-actions .app-tab.app-tab-active');
}
it('marks settings tab active on general configuration route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.configuration', $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
});
it('marks settings tab active on webhooks and other settings sub-routes', function (string $routeName) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route($routeName, $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
})->with([
'webhooks' => 'project.application.webhooks',
'domains' => 'project.application.domains',
'advanced' => 'project.application.advanced',
'environment-variables' => 'project.application.environment-variables',
'danger' => 'project.application.danger',
]);
it('does not mark settings tab active on deployment logs', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.deployment.index', $this->routeParams))
->assertSuccessful()
->getContent();
expect($html)->toContain('Deployment Logs');
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(0);
// Deployment Logs should be the active primary tab instead.
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Deployment Logs\s*<\/a>/s',
$html
))->toBe(1);
});
it('syncs activeRouteName from the page route when heading is rendered on webhooks', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.webhooks', $this->routeParams))
->assertSuccessful()
->assertSeeLivewire(ApplicationHeading::class)
->getContent();
assertSettingsTabActive($html);
});
it('keeps activeRouteName when request is not an application page route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ApplicationHeading::class, ['application' => $this->application]);
$component->set('activeRouteName', 'project.application.webhooks');
$component->call('$refresh')
->assertSet('activeRouteName', 'project.application.webhooks');
});
it('uses app-tab-active utility for resource heading active styles', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)->toContain('@utility app-tab-active');
});
+7 -2
View File
@@ -35,11 +35,16 @@ it('builds entries for every hostname without duplicates', function () {
->and(collect($records)->pluck('type')->unique()->all())->toBe(['A']);
});
it('formats a copy-paste text block with all entries', function () {
it('formats a BIND-compatible zone snippet for copy all', function () {
$text = DnsRecordHints::toCopyText([
['type' => 'A', 'name' => 'app.example.com', 'value' => '203.0.113.10'],
['type' => 'A', 'name' => 'www.example.com', 'value' => '203.0.113.10'],
['type' => 'AAAA', 'name' => 'app.example.com', 'value' => '2001:db8::1'],
]);
expect($text)->toBe("Type\tName\tValue\nA\tapp.example.com\t203.0.113.10\nA\twww.example.com\t203.0.113.10");
expect($text)->toBe(
"app.example.com. IN A 203.0.113.10\n".
"www.example.com. IN A 203.0.113.10\n".
"app.example.com. IN AAAA 2001:db8::1\n"
);
});
@@ -31,6 +31,17 @@ beforeEach(function () {
$this->actingAs($this->user);
});
it('hides preview scope for non-git applications', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerimage',
'git_repository' => null,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet('showPreview', false);
});
it('paginates managed environment variables without loading every row into the page collection', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -0,0 +1,99 @@
<?php
/**
* Resource environment variables table: full names, Managed as a column,
* Type owns Production/Preview (no desktop Production badge in the name cell).
*/
test('resource environment variables table has a Managed column and no name-cell Production badge on desktop', function () {
$all = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
// Header includes Managed between Name and Type.
expect($all)
->toContain('$showEnvironmentType = $showPreview')
->toContain("toggleVariableFilter('{{ \$value }}')")
->toContain('toggleServiceFilter(@js($serviceName))')
->toContain('$this->serviceFilterOptions')
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
->toContain('wire:loading.flex wire:target="clearFilters"')
->toContain('wire:click="clearFilters"')
->toContain('Clear filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->toContain('relative z-20 border-t')
->toContain('dark:bg-[#171717]')
->not->toContain("'all' => 'All variables'")
->toContain("setTableSort('{{ \$value }}')")
->toContain('Loading environment variables...')
->toContain('opacity-40 blur-[2px]')
->toContain('setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage')
->toContain("'buildtime' => 'Buildtime'")
->toContain("'runtime' => 'Runtime'")
->toContain("'multiline' => 'Multiline'")
->toContain("'literal' => 'Literal'")
->toContain('$activeFilterCount')
->toContain('$activeFilterText')
->toContain("'button max-w-80 min-w-0'")
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
->toContain('>Name</span>')
->toContain('>Managed</span>')
->toContain('>Type</span>');
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
->toContain('env-managed-desktop')
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile')
->toContain('env-managed-desktop data-table-cell-check')
->toContain('$isMagicVariable');
// Production/Managed desktop badges must not sit bare in the name cell without mobile class.
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,400}<span class="table-badge shrink-0">Managed<\/span>/'
);
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,500}<span class="table-badge shrink-0">\{\{\s*\$rowScopeLabel\s*\}\}<\/span>/'
);
expect($hardcoded)
->toContain('env-managed-desktop data-table-cell-check')
->toContain('title="Environment variable details"')
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile');
// Mobile-only badge classes beat .table-badge display on desktop.
expect($css)
->toContain('.env-managed-desktop')
// Resource grid is 9 columns (includes Managed).
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
expect($all)->not->toContain('<span>Comment</span>');
expect($show)->toContain('<x-helper :helper="e($comment)" />');
});
test('shared environment variables table still omits Managed column', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($editor)
->toContain('env-table-grid-shared')
->not->toContain('>Managed</span>');
// Shared rows skip the Managed column cell.
expect($show)->toContain('! $isSharedVariable');
});
test('managed environment variables are ordered first', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
expect($component)
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
->toMatch("/'kind' => 'hardcoded',[\\s\\S]+?'kind' => 'managed'/");
});
+18 -3
View File
@@ -87,7 +87,7 @@ test('livewire file storage rejects parent segments and does not create a local
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Create a managed file on the host and mount it inside the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
@@ -136,7 +136,10 @@ test('livewire volume storage refreshes the storage list and configuration warni
->call('submitPersistentVolume')
->assertDispatched('success')
->assertDispatched('refreshStorages')
->assertDispatched('configurationChanged');
->assertDispatched('configurationChanged')
->assertSet('activeTab', 'volumes')
->assertSet('volumeCount', 1)
->assertSee($this->application->uuid.'-data');
});
test('volume storage list shows volumes added after it was mounted', function () {
@@ -159,10 +162,22 @@ test('volume storage list shows volumes added after it was mounted', function ()
$storageList
->assertDontSee($secondVolume->name)
->dispatch('refreshStorages')
->call('refreshList')
->assertSee($secondVolume->name);
});
test('adding a volume switches to the volumes tab immediately', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSet('activeTab', 'volumes')
->set('activeTab', 'directories')
->set('name', 'cache')
->set('mount_path', '/cache')
->call('submitPersistentVolume')
->assertSet('activeTab', 'volumes')
->assertSee($this->application->uuid.'-cache')
->assertDontSee('No directory mounts configured');
});
test('deleting a file mount refreshes the configuration warning', function () {
$file = LocalFileVolume::create([
'fs_path' => '/etc/nginx/nginx.conf',
+50
View File
@@ -0,0 +1,50 @@
<?php
test('default form buttons and inputs share the same control height', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)
->toContain('@utility input-select {')
->toContain('@utility button {');
preg_match('/@utility input-select \{[^}]*\}/s', $utilities, $inputSelect);
preg_match('/@utility button \{[^}]*\}/s', $utilities, $button);
expect($inputSelect[0] ?? '')
->toContain('h-9')
->and($button[0] ?? '')->toContain('h-9')
->and($button[0] ?? '')->toContain('min-h-9')
->and($button[0] ?? '')->toContain('whitespace-nowrap')
->and($button[0] ?? '')->toContain('shrink-0')
->and($button[0] ?? '')->not->toContain('h-8');
});
test('settings form surfaces keep input and button heights equal', function () {
$css = file_get_contents(resource_path('css/app.css'));
preg_match(
'/\.application-settings-workspace \.input,[\s\S]*?\.application-settings-form \.select \{[\s\S]*?\}/',
$css,
$inputs
);
preg_match(
'/\.application-settings-workspace \.button,[\s\S]*?\.application-settings-form \.button \{[\s\S]*?\}/',
$css,
$buttons
);
expect($inputs[0] ?? '')
->toContain('height: 2rem;')
->and($buttons[0] ?? '')->toContain('height: 2rem;')
->and($buttons[0] ?? '')->toContain('min-height: 2rem;')
->and($buttons[0] ?? '')->toContain('white-space: nowrap;');
});
test('directory storage actions wrap on narrow viewports instead of stacking uneven heights', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php'));
expect($view)
->toContain('flex flex-wrap items-center gap-2')
->toContain('Convert to file')
->toContain('Configure Backup');
});
+1
View File
@@ -12,6 +12,7 @@ test('helper trigger is a button that stops label activation', function () {
->toContain('aria-label="More information"')
->toContain('info-helper-popup')
->toContain('name="info-circle"')
->toContain('class="size-3.5 text-neutral-400')
->not->toContain('<div x-ref="trigger" class="info-helper"');
});
@@ -44,6 +44,28 @@ test('searchable listbox component uses shared trigger label truncation', functi
->toContain(':title="current"');
});
test('listbox shows an empty state when it has no options', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($listbox)
->toContain("'emptyText' => 'No options available.'")
->toContain('x-show="options.length === 0"');
expect($operations)->toContain('No network destinations are available on this server.');
});
test('listbox forwards dynamic disabled state to its trigger', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($listbox)->toContain("\$attributes->whereStartsWith('x-bind:disabled')");
expect($operations)
->toContain('x-bind:disabled="!selectedCloneServer"')
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
});
test('notification event multiselect truncates long selected summaries', function () {
$html = Blade::render(<<<'BLADE'
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
@@ -65,10 +65,13 @@ it('renders the changed configuration labels', function () {
$application->update(['build_command' => 'pnpm build']);
// Banner summary is always available; full change rows load on demand.
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('The latest configuration has not been applied')
->assertSee('Build command')
->assertSee('A rebuild is required.');
->assertSee('A rebuild is required.')
->assertDontSee('Build command')
->call('refreshConfigurationChanges')
->assertSee('Build command');
});
it('refreshes configuration changes when the event is received', function () {
@@ -85,6 +88,7 @@ it('refreshes configuration changes when the event is received', function () {
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->call('refreshConfigurationChanges')
->assertSee('Build command');
});
@@ -108,8 +112,9 @@ it('shows an unapplied configuration warning after a directory mount is added',
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Directory mount')
->assertSee('Please redeploy to apply the new configuration.');
->assertSee('Please redeploy to apply the new configuration.')
->call('refreshConfigurationChanges')
->assertSee('Directory mount');
});
it('refreshes stale modal configuration diff before opening changes', function () {
@@ -119,6 +124,7 @@ it('refreshes stale modal configuration diff before opening changes', function (
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('Build command')
->assertDontSee('Start command');
@@ -134,7 +140,29 @@ it('refreshes stale modal configuration diff before opening changes', function (
->assertDontSee('Build command');
});
it('does not render environment variable secret values', function () {
it('keeps full configuration change rows out of the initial Livewire snapshot', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
expect($component->get('isConfigurationChanged'))->toBeTrue()
->and($component->get('configurationDiff'))->toHaveKeys(['count', 'requires_build'])
->and($component->get('configurationDiff'))->not->toHaveKey('changes');
$component->call('refreshConfigurationChanges');
expect($component->get('configurationDiff'))->toHaveKey('changes')
->and(data_get($component->get('configurationDiff'), 'changes'))->not->toBeEmpty();
});
it('redacts unlocked environment values for team members in the change list', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$application = configurationCheckerApplication($this->environment);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
@@ -149,15 +177,26 @@ it('does not render environment variable secret values', function () {
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('old-secret')
->assertDontSee('new-secret');
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges');
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|| str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
expect($envChange)->not->toBeNull()
->and(data_get($envChange, 'old_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'old_full_value'))->toBeNull()
->and(data_get($envChange, 'new_full_value'))->toBeNull();
});
it('renders added environment variables as set without exposing secret values', function () {
it('redacts newly added environment values for team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
@@ -171,12 +210,18 @@ it('renders added environment variables as set without exposing secret values',
'resourceable_id' => $application->id,
]);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('API_TOKEN')
->assertSee('From')
->assertSee('-')
->assertSee('To')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('new-secret');
->assertSee('Current')
->assertSee('New');
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|| str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
expect($envChange)->not->toBeNull()
->and(data_get($envChange, 'old_display_value'))->toBe('-')
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'type'))->toBe('added');
});
@@ -0,0 +1,106 @@
<?php
use App\Livewire\Project\Application\Advanced;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createApplicationForContainerNamingTest(): Application
{
$team = Team::factory()->create();
$team->members()->attach(auth()->id(), ['role' => 'owner']);
session(['currentTeam' => $team]);
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::create([
'name' => 'container-naming-test-app',
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
]);
}
beforeEach(function () {
$this->actingAs(User::factory()->create());
});
it('saves consistent container naming when container labels are managed manually', function () {
$application = createApplicationForContainerNamingTest();
// Renders the proxy empty-state that builds a configuration route.
$application->settings->update([
'is_container_label_readonly_enabled' => false,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('isConsistentContainerNameEnabled', true)
->call('instantSave')
->assertSuccessful()
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Go to Container labels');
expect($application->settings()->first()->is_consistent_container_name_enabled)->toBeTrue();
});
it('renders the configuration link from application uuids when labels are manual', function () {
$application = createApplicationForContainerNamingTest();
$application->settings->update([
'is_container_label_readonly_enabled' => false,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
$expectedHref = route('project.application.configuration', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]).'#container-labels-section';
Livewire::test(Advanced::class, ['application' => $application])
->assertSuccessful()
->assertSee($expectedHref, false);
});
it('toggles consistent naming when labels are managed by coolify', function () {
$application = createApplicationForContainerNamingTest();
$application->settings->update([
'is_container_label_readonly_enabled' => true,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('isConsistentContainerNameEnabled', true)
->call('instantSave')
->assertSuccessful()
->assertHasNoErrors()
->assertDispatched('success');
expect($application->settings()->first()->is_consistent_container_name_enabled)->toBeTrue();
});
it('only shows the custom container name for consistent naming', function () {
$application = createApplicationForContainerNamingTest();
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->assertDontSee('Custom container name')
->set('isConsistentContainerNameEnabled', true)
->assertSee('Custom container name');
});
@@ -0,0 +1,26 @@
<?php
/**
* Active sidebar/nav items use a solid fill only — no left accent rail
* (::before) and no gradient wash.
*/
test('active menu items do not render an accent rail', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
preg_match('/@utility menu-item-active \{[^}]*\}/s', $utilities, $menuItemActive);
preg_match('/@utility menu-subitem-active \{[^}]*\}/s', $utilities, $menuSubitemActive);
expect($menuItemActive[0] ?? '')
->toContain('rounded-md')
->toContain('bg-black/[0.05]')
->and($menuSubitemActive[0] ?? '')
->toContain('rounded-md')
->toContain('bg-black/[0.05]');
// Accent rail must be disabled (content: none), not drawn as a 3px accent bar.
expect($appCss)
->toMatch('/\.menu-item-active::before,\s*\.menu-subitem-active::before\s*\{[^}]*content:\s*none/s')
->not->toMatch('/\.menu-item-active::before\s*\{[^}]*width:\s*3px/s')
->not->toMatch('/\.menu-item-active::before\s*\{[^}]*background:\s*var\(--color-accent\)/s');
});
+30
View File
@@ -0,0 +1,30 @@
<?php
/**
* Inactive sidebar/nav menu items previously used dark:text-fg-faint (#6e6e74)
* on near-black app chrome (~3.9:1), below WCAG AA for normal text. Defaults
* should use fg-dim / neutral-600 instead.
*/
test('inactive menu items use accessible contrast tokens', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
preg_match('/@utility menu-item \{[^}]*\}/s', $utilities, $menuItem);
preg_match('/@utility menu-subitem \{[^}]*\}/s', $utilities, $menuSubitem);
preg_match('/@utility sub-menu-item \{[^}]*\}/s', $utilities, $subMenuItem);
preg_match('/@utility nav-section \{[^}]*\}/s', $utilities, $navSection);
expect($menuItem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->not->toContain('dark:text-fg-faint')
->and($menuSubitem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->not->toContain('dark:text-fg-faint')
->and($subMenuItem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->and($navSection[0] ?? '')
->toContain('dark:text-fg-dim')
->not->toContain('dark:text-fg-faint');
});
@@ -0,0 +1,189 @@
<?php
use App\Livewire\Project\Application\Backup\Create;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Process::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false]
));
});
/**
* @return array{0: Application, 1: LocalPersistentVolume, 2: Team}
*/
function createPerfApplicationWithVolumes(int $volumeCount = 5): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
test()->actingAs($user);
session(['currentTeam' => $team]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'ip' => '203.0.113.10',
]);
$server->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'nixpacks',
]);
$application->setRelation('environment', $environment);
$environment->setRelation('project', $project);
$firstVolume = null;
for ($i = 0; $i < $volumeCount; $i++) {
$volume = LocalPersistentVolume::create([
'name' => $application->uuid.'-vol-'.$i,
'mount_path' => '/data/'.$i,
'host_path' => null,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
$firstVolume ??= $volume;
}
LocalFileVolume::create([
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/config.env',
'mount_path' => '/app/config.env',
'content' => str_repeat('x', 5000),
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$application = $application->fresh(['environment.project', 'destination.server', 'persistentStorages']);
return [$application, $firstVolume, $team];
}
it('renders volume rows without nesting Livewire Show components', function () {
[$application] = createPerfApplicationWithVolumes(5);
$html = Livewire::test(All::class, ['resource' => $application])->html();
expect($html)
->toContain('data-table')
->toContain('openBackupModal')
->toContain('wire:submit="submit(')
->not->toContain('livewire:project.shared.storages.show')
->not->toContain('shared-configure-volume-backup-');
});
it('batches volume backup meta and exposes forms for every volume', function () {
[$application, , $team] = createPerfApplicationWithVolumes(5);
foreach ($application->persistentStorages as $storage) {
$storage->scheduledBackups()->create([
'team_id' => $team->id,
'frequency' => 'daily',
'enabled' => true,
]);
}
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('volumeBackupMeta'))->toHaveCount(5)
->and($component->get('forms'))->toHaveCount(5);
foreach ($component->get('volumeBackupMeta') as $meta) {
expect($meta['enabled'])->toBeTrue()
->and($meta['url'])->not->toBeNull();
}
});
it('updates a volume row from the parent All component', function () {
[$application, $volume] = createPerfApplicationWithVolumes(2);
Livewire::test(All::class, ['resource' => $application])
->set("forms.{$volume->id}.mountPath", '/data/updated')
->call('submit', $volume->id)
->assertDispatched('success');
expect($volume->fresh()->mount_path)->toBe('/data/updated');
});
it('mounts a single shared backup modal only after openBackupModal', function () {
[$application, $volume] = createPerfApplicationWithVolumes(3);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->html())
->toContain('openBackupModal')
->not->toContain('shared-configure-volume-backup-')
->and($component->get('backupModalStorageId'))->toBeNull();
$component
->call('openBackupModal', $volume->id)
->assertSet('backupModalStorageId', $volume->id)
->assertSee('Frequency');
});
it('keeps file mount content out of the volumes tab snapshot', function () {
[$application] = createPerfApplicationWithVolumes(2);
$component = Livewire::test(Storage::class, ['resource' => $application]);
expect($component->get('activeTab'))->toBe('volumes')
->and($component->get('fileCount'))->toBe(1)
->and($component->get('volumeCount'))->toBe(2)
->and(collect($component->get('fileStorage')))->toHaveCount(0);
$component->call('setActiveTab', 'files')
->assertSet('activeTab', 'files');
expect(collect($component->get('fileStorage')))->toHaveCount(1);
});
it('loads only the locked target when opening backup create from a volume row', function () {
[$application, $volume] = createPerfApplicationWithVolumes(4);
DB::flushQueryLog();
DB::enableQueryLog();
$component = Livewire::test(Create::class, [
'application' => $application,
'selectedTargetKey' => 'volume:'.$volume->id,
]);
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
expect($component->get('targetLocked'))->toBeTrue()
->and($component->get('targets'))->toHaveCount(1)
->and($queryCount)->toBeLessThan(15);
});
@@ -0,0 +1,203 @@
<?php
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false]
));
$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]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createApplicationWithVolume(array $applicationAttributes = [], array $volumeAttributes = []): array
{
$application = Application::factory()->create(array_merge([
'uuid' => (string) Str::uuid(),
'name' => 'Storage App',
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'nixpacks',
], $applicationAttributes));
$volume = LocalPersistentVolume::create(array_merge([
'uuid' => (string) Str::uuid(),
'name' => $application->uuid.'-data',
'mount_path' => '/data',
'host_path' => null,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
'is_preview_suffix_enabled' => true,
], $volumeAttributes));
return [$application, $volume];
}
it('renders volumes as a data table with shared column headers', function () {
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
expect($allView)
->toContain('data-table')
->toContain('data-table-header')
->toContain('volumes-table-grid')
->toContain('volumes-table-grid-readonly')
->toContain('Volume Name')
->toContain('Source Path')
->toContain('Destination Path')
->toContain('supportsPreviewSuffix')
->toContain('openBackupModal')
->toContain('data-table-row')
->toContain('volumes-mobile-label')
->toContain('table-badge-success')
->not->toContain('livewire:project.shared.storages.show')
->not->toContain('x-status-badge');
// Show remains available for isolated embeds/tests but is no longer nested from All.
expect($showView)
->toContain('data-table-row')
->toContain('volumes-table-grid');
// Service stack page: one settings-section card per compose service/resource.
expect($storageView)
->toContain('Str::headline($resource->name)')
->toContain(':flush="true"')
->toContain('storage-service-');
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.volumes-table-grid')
->toContain('.volumes-table-grid-with-pr')
->toContain('.volumes-table-grid-readonly')
->toContain('.volumes-mobile-label')
->toContain('font-size: 13px') // same as .application-settings-form label
->toContain('@media (max-width: 768px)')
->toContain('.table-badge-success');
// Settings form labels are 13px (not Tailwind text-sm 14px).
expect($css)
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
});
it('shows PR deployment suffix only for git-based applications', function () {
[$gitApp] = createApplicationWithVolume(['build_pack' => 'nixpacks']);
Livewire::test(All::class, ['resource' => $gitApp])
->assertSet('supportsPreviewSuffix', true)
->assertSee('Add suffix');
[$dockerImageApp] = createApplicationWithVolume([
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
]);
Livewire::test(All::class, ['resource' => $dockerImageApp])
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
});
it('hides PR deployment suffix for databases', function () {
$database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'pg-test',
'postgres_password' => 'secret',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
LocalPersistentVolume::create([
'uuid' => (string) Str::uuid(),
'name' => $database->uuid.'-data',
'mount_path' => '/var/lib/postgresql/data',
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
Livewire::test(All::class, ['resource' => $database])
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
});
it('uses a compact table badge for enabled backups instead of status-badge', function () {
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
expect($showView)
->toContain('table-badge-success')
->toContain('Volume backup is enabled')
->not->toContain('x-status-badge')
->not->toContain('status="Backup enabled"');
// Badge label is the short "Backup" text, not the old pill-with-label that broke the input row.
expect(preg_match('/table-badge-success[^>]*>\s*Backup\s*</', $showView))->toBeGreaterThan(0);
});
it('gates file storage PR suffix markup behind git_based applications', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php'));
expect($view)
->toContain('$resource->git_based()')
->toContain('PR deployment suffix');
});
@@ -0,0 +1,11 @@
<?php
test('resource table subtitle shows description only and never falls back to uuid', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain('x-show="item.description"')
->toContain('x-text="item.description"')
->not->toContain('item.description || item.fqdn || item.uuid')
->not->toContain('item.fqdn || item.uuid');
});
@@ -0,0 +1,119 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::create([
'name' => 'default',
'network' => 'coolify',
'server_id' => $this->server->id,
]);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'coolLabs',
]);
$this->environment = $this->project->environments()->firstOrFail();
});
/**
* @js() embeds JSON with unicode-escaped quotes (\u0022). Match that encoding.
*/
function assertJsPayloadContains(string $html, string $needle): void
{
$escaped = str_replace('"', '\u0022', $needle);
expect($html)->toContain($escaped);
}
function assertJsPayloadDoesNotContain(string $html, string $needle): void
{
$escaped = str_replace('"', '\u0022', $needle);
expect($html)->not->toContain($escaped);
}
test('resource index type labels use category names not engine names for databases', function () {
$mysql = StandaloneMysql::create([
'name' => 'mysql-database-uprlcxnoukmrxge65gdrgwqm',
'mysql_root_password' => 'password',
'mysql_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'exited:unhealthy',
]);
StandalonePostgresql::create([
'name' => 'postgresql-database-test',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'exited:unhealthy',
]);
Application::create([
'name' => 'docker-image-test',
'fqdn' => 'https://example.com',
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => '80',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Service::create([
'name' => 'actualbudget-test',
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => "services:\n app:\n image: nginx\n",
'docker_compose' => "services:\n app:\n image: nginx\n",
'service_type' => 'actualbudget',
]);
$response = $this->get(route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]));
$response->assertSuccessful();
$html = $response->getContent();
// Category labels match Application / Service — not engine-specific names.
assertJsPayloadContains($html, '"type":"database"');
assertJsPayloadContains($html, '"typeLabel":"Database"');
assertJsPayloadContains($html, '"type":"application"');
assertJsPayloadContains($html, '"typeLabel":"Application"');
assertJsPayloadContains($html, '"type":"service"');
assertJsPayloadContains($html, '"typeLabel":"Service"');
assertJsPayloadDoesNotContain($html, '"typeLabel":"MySQL"');
assertJsPayloadDoesNotContain($html, '"typeLabel":"PostgreSQL"');
expect($html)->toContain($mysql->name);
});
@@ -0,0 +1,16 @@
<?php
/**
* Docker CPU constraints docs link belongs in the CPU section header as a button.
*/
test('resource limits cpu docs link is a header action button', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/resource-limits.blade.php'));
expect($view)
->toContain('<x-slot:actions>')
->toContain('Docker CPU constraints')
->toContain('https://docs.docker.com/engine/containers/resource_constraints/#cpu')
->toContain('class="button"')
->toContain('name="external-link"')
->not->toContain('mt-4 inline-flex items-center gap-1 text-xs text-neutral-500');
});
+47 -5
View File
@@ -177,6 +177,21 @@ it('auto-adds missing non-www pair for a service application redirect', function
->toContain('https://api.example.com');
});
it('auto-adds the suggested www pair when adding a service domain with both directions', function () {
$this->webApp->update(['redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://web.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
});
it('adds a domain to a selected service application', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
@@ -186,7 +201,8 @@ it('adds a domain to a selected service application', function () {
->assertDispatched('success');
$this->webApp->refresh();
expect($this->webApp->fqdn)->toBe('https://web.example.com');
expect(explode(',', (string) $this->webApp->fqdn))
->toBe(['https://web.example.com', 'https://www.web.example.com']);
});
it('rolls back a domain change when compose regeneration fails', function () {
@@ -282,8 +298,10 @@ it('does not restore stale dns status when a removed service domain is re-added'
$this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://api.example.com')
->and($this->apiApp->domain_dns_statuses)->toBeNull();
expect(explode(',', (string) $this->apiApp->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com'])
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['status'] ?? null)->toBe('skipped')
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
});
it('saves after confirming both a domain conflict and a missing required port', function () {
@@ -317,7 +335,8 @@ YAML,
->assertSet('pendingAction', null)
->assertDispatched('success');
expect($this->webApp->fresh()->fqdn)->toBe('https://api.example.com');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com']);
});
it('loads persisted dns status for service applications', function () {
@@ -333,7 +352,30 @@ it('loads persisted dns status for service applications', function () {
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS mismatch stored.');
->assertSee('DNS mismatch')
->assertDontSee('DNS mismatch stored.')
->call('openDnsRecordsModal')
->assertSet('showDnsRecordsModal', true);
});
it('shows service dns mismatches before other domain entries', function () {
$this->webApp->update([
'fqdn' => 'https://healthy.example.com',
'domain_dns_statuses' => [
'https://healthy.example.com' => ['status' => 'ok', 'message' => 'OK'],
],
]);
$this->apiApp->update([
'fqdn' => 'https://broken.example.com',
'domain_dns_statuses' => [
'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.url', 'https://broken.example.com')
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.2.url', 'https://healthy.example.com');
});
it('hides dns message text when service domain dns status is ok', function () {
@@ -0,0 +1,46 @@
<?php
/**
* Settings nav sub-items scroll a section into view and flash its border
* for 500ms so the user can see which card was targeted.
*/
test('settings section highlight animation is defined for 500ms', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
->toContain('@keyframes application-settings-section-highlight')
->toContain('.application-settings-section.is-section-highlight')
->toContain('.application-settings-section.is-section-highlight::after')
->toContain('animation: application-settings-section-highlight 500ms ease-out forwards')
->toContain('border: 0.5px solid var(--color-accent)')
->toContain('var(--color-accent)');
});
test('settings navigation leaves room for the default tab focus ring', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
preg_match('/\.application-settings-navigation\s*\{[^}]*\}/s', $appCss, $nav);
// Tab focus keeps the global ring-2 + ring-offset-2; do not thin it.
expect($appCss)
->not->toContain('.menu-item:focus-visible')
->not->toContain('box-shadow: inset 0 0 0 0.5px var(--color-accent)')
->and($nav[0] ?? '')
->toContain('padding-right: 0.375rem');
});
test('configuration sidebar subitems trigger section highlight on scroll', function () {
$blade = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$appJs = file_get_contents(resource_path('js/app.js'));
expect($blade)
->toContain('scrollToSection(id)')
->toContain('window.scrollToSettingsSection?.(id)')
->toContain("scrollToSection('{{ \$section['id'] }}')")
->and($appJs)
->toContain('window.scrollToSettingsSection')
->toContain("el.classList.add('is-section-highlight')")
->toContain("behavior: 'smooth'")
->toContain("addEventListener('scrollend'")
->toContain('stableFrames');
});
+14 -8
View File
@@ -366,14 +366,16 @@ it('shows the configure backup modal trigger inside the volume card instead of i
'resource' => $application,
])
->set('isReadOnly', true)
->assertSee('Configure Backup')
->assertSee('Backup')
->assertDontSee('Backups made while the application is writing');
$html = $component->html();
expect(strpos($html, 'Configure Backup'))
->toBeGreaterThan(strpos($html, '<form'))
->toBeLessThan(strpos($html, '</form>'));
// Read-only volume rows are table cells (no form); backup action still renders in the row.
expect($html)
->toContain('Configure Volume Backup')
->toContain('data-table-row')
->toContain('Backup');
});
it('only shows the backup enabled badge for an enabled volume backup', function () {
@@ -391,7 +393,7 @@ it('only shows the backup enabled badge for an enabled volume backup', function
$component = Livewire::test(Show::class, [
'storage' => $volume,
'resource' => $application,
])->assertDontSee('Backup enabled');
])->assertDontSee('table-badge-success', false);
$backup->update(['enabled' => true]);
@@ -404,14 +406,17 @@ it('only shows the backup enabled badge for an enabled volume backup', function
$component
->dispatch('refreshVolumeBackups')
->assertSeeInOrder(['Volume Name', 'Backup enabled'])
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
Livewire::test(Show::class, [
'storage' => $volume,
'resource' => $application,
'isFirst' => false,
])->assertSeeInOrder(['Volume Name', 'Backup enabled']);
])
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled');
});
it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () {
@@ -441,7 +446,8 @@ it('links the backup enabled badge to a filtered backup list when the applicatio
'storage' => $volume,
'resource' => $application,
])
->assertSee('Backup enabled')
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
});
+3 -3
View File
@@ -20,7 +20,7 @@ it('defaults to A when the address is missing or not an ip', function () {
it('builds a short A-record guidance message for ipv4 targets', function () {
$message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3');
expect($message)->toBe('A record → 172.16.0.3')
expect($message)->toBe('Required DNS record type A pointing to 172.16.0.3')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
@@ -28,14 +28,14 @@ it('builds a short A-record guidance message for ipv4 targets', function () {
it('builds a short AAAA-record guidance message for ipv6 targets', function () {
$message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1');
expect($message)->toBe('AAAA record → 2001:db8::1')
expect($message)->toBe('Required DNS record type AAAA pointing to 2001:db8::1')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
it('prefers the bare ip over a hostname label', function () {
expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3'))
->toBe('A record → 172.16.0.3');
->toBe('Required DNS record type A pointing to 172.16.0.3');
});
it('falls back when no target is available', function () {