mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 12:16:45 +00:00
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:
1 parent
dbc6f5e08c
commit
a7a06aa6c8
75 files changed
+3909
-1173
No files matched your search
@@ -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');
|
||||
});
|
||||
@@ -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'/");
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user