assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false)
->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false)
->assertSee('x-on:error="$el.remove()"', false)
- ->assertSee('class="min-w-0 flex-1 text-[13px]', false)
+ ->assertSee('class="min-w-0 flex-1 truncate text-[13px]', false)
->html();
- expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
+ expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0);
});
it('shows the HTTP redirect control for HTTPS domains and persists changes', function () {
@@ -645,7 +645,7 @@
->assertDontSee('Redirect HTTP to HTTPS');
});
-it('shows one redirect direction control in each compose service header', function () {
+it('shows the compose service redirect control in domain settings', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n",
@@ -660,11 +660,13 @@
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSuccessful()
->assertSee('api')
+ ->call('startEdit', 0)
+ ->assertSee('www redirect')
->html();
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
- ->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0);
+ ->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0);
});
it('shows dns entries control next to Add', function () {
@@ -805,7 +807,7 @@
it('adds a domain to the application', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
- ->assertSee('+ Add')
+ ->assertSee('Add domain')
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
@@ -909,7 +911,7 @@
->call('startEdit', 0)
->assertSet('showEditDomainModal', true)
->assertSet('editingDomain', 'https://old.example.com')
- ->assertSee('Direction')
+ ->assertSee('www redirect')
->assertSee('Search engine indexing')
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'new.example.com')
@@ -1287,7 +1289,7 @@
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.dns_status', 'ok')
- ->assertSee('DNS OK')
+ ->assertSee('DNS matches')
->assertDontSee('DNS points to 203.0.113.10')
->assertDontSee('Last checked');
});
@@ -1886,33 +1888,33 @@
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
- ->toContain('class="data-table-header domains-table-grid-service"')
- ->toContain('Direction')
- ->toContain('Search engine indexing')
+ ->toContain('class="data-table-header service-domains-overview-grid"')
+ ->toContain('Domain redirect')
+ ->toContain('Search indexing')
->not->toContain('Last checked')
->not->toContain('id="edit-domain-direction"')
- ->toContain('id="domain-direction-service-{{ $redirectWireKey }}"')
- ->toContain('onChange="updateServiceRedirect"')
- ->toContain("'showDirectionControl' => false")
+ ->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
+ ->toContain('id="application-domain-direction-{{ $editingKey }}"')
+ ->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
->not->toContain('title="No domains for this service"');
});
-it('keeps search engine indexing table headers on one line', function () {
+it('uses concise search indexing headers in application and service domain tables', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
- expect(substr_count($applicationView, 'Search engine indexing'))
- ->toBe(2)
+ expect(substr_count($applicationView, 'Search indexing'))
+ ->toBe(1)
->and($serviceView)
- ->toContain('Search engine indexing');
+ ->toContain('Search indexing');
});
-it('shows domain guidance in the application domains section', function () {
+it('shows save guidance in the application domain settings', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
- ->toContain('')
- ->toContain('{{ $helperText }}');
+ ->toContain('Indexing and redirect changes save automatically.')
+ ->toContain('toContain('@media (max-width: 768px)')
- ->toContain('.domains-mobile-label')
- ->toContain('.domains-table-grid .listbox-trigger')
+ ->toContain('@container service-domains (max-width: 980px)')
+ ->toContain('.service-domain-detail-label')
+ ->toContain('.service-domains-overview-grid')
->and($row)
- ->toContain('domains-mobile-label')
- ->toContain('Search engine indexing')
- ->toContain('Direction');
+ ->toContain('service-domain-detail-label')
+ ->toContain('Search indexing')
+ ->toContain('Domain redirect');
});
it('uses segmented fields when adding and editing application domains', function () {
@@ -2132,10 +2134,11 @@
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('startEdit', 0)
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('Search engine indexing')
- ->assertSee('Direction')
+ ->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateRedirect', false)
->assertSee('wire:ignore', false)
@@ -2376,7 +2379,7 @@
->not->toContain('Inherited from Ports Exposes');
});
-it('shows an error badge when a domain has no internal port and ports exposes is empty', function () {
+it('shows a warning when a domain has no internal port and ports exposes is empty', function () {
$this->application->update([
'ports_exposes' => null,
'fqdn' => 'https://example.com',
@@ -2387,7 +2390,8 @@
->assertSet('domainRows.0.internal_port', null)
->assertSee('No internal port')
->assertDontSee('Internal port ')
- ->assertSee('table-badge-danger', false);
+ ->assertSee('aria-label="No internal port"', false)
+ ->assertSee('Set Ports Exposes or a per-domain internal port', false);
});
it('keeps the internal port badge when a domain override exists without ports exposes', function () {
@@ -2413,7 +2417,7 @@
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 3000')
- ->assertSee('Inherited from Ports Exposes', false)
+ ->assertSee('Inherited from the application or Compose service port', false)
->assertDontSee('Custom internal port for this domain', false);
});
@@ -2887,3 +2891,141 @@ function applicationDomainPortOverrideApiToken(User $user, Team $team): string
expect($this->application->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070);
});
+
+it('keeps the selected application domain when a refresh reorders dns rows', function (bool $compose) {
+ $first = 'https://first.example.com';
+ $second = 'https://second.example.com';
+ $this->application->update($compose ? [
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [80]\n",
+ 'docker_compose_domains' => json_encode(['web' => ['domain' => "$first,$second"]]),
+ ] : ['fqdn' => "$first,$second"]);
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('startEdit', 0)
+ ->set('editingDomainParts.host', 'renamed.example.com');
+ $this->application->update(['domain_dns_statuses' => [
+ ($compose ? 'web|' : '').$second => ['status' => 'failed', 'message' => 'Mismatch'],
+ ]]);
+
+ $component->call('refreshDomains')
+ ->assertSet('editingIndex', 1)
+ ->assertSet('editingDomainParts.host', 'renamed.example.com')
+ ->call('toggleNoindexDomain', $first, 'noindex')
+ ->call('updateDomain')
+ ->assertHasNoErrors();
+
+ $this->application->refresh();
+ $domains = $compose ? json_decode($this->application->docker_compose_domains, true)['web']['domain'] : $this->application->fqdn;
+ expect(explode(',', $domains))->toBe(['https://renamed.example.com', $second]);
+})->with([false, true]);
+
+it('inherits application counterpart ports without changing configured counterparts', function (bool $compose, ?int $override, string $redirect) {
+ $host = $redirect === 'www' ? 'app.example.com' : 'www.app.example.com';
+ $counterpart = $redirect === 'www' ? 'www.app.example.com' : 'app.example.com';
+ $url = "https://$host/blog";
+ $pairedUrl = "https://$counterpart/blog";
+ $existing = 'http://existing.example.com,https://www.existing.example.com';
+ $this->application->update(array_merge([
+ 'ports_exposes' => '80',
+ 'domain_port_overrides' => array_filter([
+ $url => $override,
+ 'https://www.existing.example.com' => 9090,
+ ], fn ($port) => $port !== null),
+ ], $compose ? [
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [80]\n",
+ 'docker_compose_domains' => json_encode(['web' => ['domain' => "$url,$existing"]]),
+ ] : ['fqdn' => "$url,$existing"]));
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
+ if ($compose) {
+ $component->call('updateServiceRedirect', 'web', $redirect);
+ } else {
+ $component->call('updateRedirect', $redirect);
+ }
+ $component->assertHasNoErrors()->assertSet('showPortWarningModal', false);
+
+ $this->application->refresh();
+ $domains = $compose ? json_decode($this->application->docker_compose_domains, true)['web']['domain'] : $this->application->fqdn;
+ expect($domains)->toContain($pairedUrl)->toContain($existing)
+ ->and($this->application->domain_port_overrides[$pairedUrl] ?? 80)->toBe($override ?? 80)
+ ->and($this->application->domain_port_overrides[$url] ?? null)->toBe($override)
+ ->and($this->application->domain_port_overrides['https://www.existing.example.com'])->toBe(9090);
+})->with([false, true])->with([null, 8080])->with(['www', 'non-www']);
+
+it('preserves pending application redirect through refresh and resolves its domain conflict', function (bool $compose, bool $cancel) {
+ Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'fqdn' => 'https://www.pending.example.com',
+ 'build_pack' => 'nixpacks',
+ ]);
+ $url = 'https://pending.example.com';
+ $this->application->update(array_merge([
+ 'domain_port_overrides' => [$url => 8080],
+ ], $compose ? [
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => "services:\n web.app:\n image: nginx:alpine\n expose: [80]\n",
+ 'docker_compose_domains' => json_encode([
+ 'web.app' => ['domain' => $url, 'redirect' => 'both'],
+ 'occupied' => ['domain' => 'https://www.pending.example.com'],
+ ]),
+ ] : ['fqdn' => $url]));
+
+ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
+ $property = $compose ? 'serviceRedirects.'.str_replace('.', '__dot__', 'web.app') : 'redirect';
+ if ($compose) {
+ $component->call('updateServiceRedirect', 'web.app', 'www');
+ } else {
+ $component->call('updateRedirect', 'www');
+ }
+ $component->assertSet('showDomainConflictModal', true)
+ ->call('refreshDomains')
+ ->assertSet($property, 'www');
+
+ if ($cancel) {
+ $component->set('showDomainConflictModal', false)
+ ->assertSet($property, 'both')
+ ->assertSet('pendingAction', null)
+ ->assertSet('pendingRedirectService', null);
+ } else {
+ $component->call('confirmDomainUsage')
+ ->assertHasNoErrors()
+ ->assertSet('pendingAction', null)
+ ->assertSet('pendingRedirectService', null)
+ ->assertSet($property, 'www');
+ }
+
+ $this->application->refresh();
+ $storedRedirect = $compose ? json_decode($this->application->docker_compose_domains, true)['web.app']['redirect'] : $this->application->redirect;
+ expect($storedRedirect)->toBe($cancel ? 'both' : 'www');
+ if (! $cancel) {
+ expect($this->application->domain_port_overrides['https://www.pending.example.com'] ?? null)->toBe(8080);
+ }
+})->with([false, true])->with([false, true]);
+
+it('keeps the selected application domain when removing an earlier row', function () {
+ $this->application->update(['fqdn' => 'https://first.example.com,https://second.example.com']);
+
+ Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('startEdit', 1)
+ ->call('removeDomain', 0)
+ ->assertSet('editingIndex', 0)
+ ->set('editingDomainParts.host', 'renamed.example.com')
+ ->call('updateDomain')
+ ->assertHasNoErrors();
+
+ expect($this->application->fresh()->fqdn)->toBe('https://renamed.example.com');
+});
+
+it('prevents members from cancelling protected application redirect conflict state', function () {
+ $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
+ $this->actingAs($this->user->fresh());
+
+ Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->set('showDomainConflictModal', true)
+ ->set('showDomainConflictModal', false)
+ ->assertForbidden();
+});
diff --git a/tests/Feature/ApplicationPreviewSettingsTest.php b/tests/Feature/ApplicationPreviewSettingsTest.php
new file mode 100644
index 000000000..5a938e0d0
--- /dev/null
+++ b/tests/Feature/ApplicationPreviewSettingsTest.php
@@ -0,0 +1,139 @@
+withoutVite();
+ config(['app.maintenance.driver' => 'file']);
+
+ 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]);
+
+ $key = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
+ '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' => $key->id,
+ '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]);
+
+ $this->application = Application::factory()->create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Preview App',
+ 'preview_url_template' => '{{pr_id}}.{{domain}}',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'fqdn' => null,
+ 'redirect' => 'both',
+ 'build_pack' => 'nixpacks',
+ ]);
+
+ $this->application->settings()->update([
+ 'is_container_label_readonly_enabled' => true,
+ ]);
+});
+
+it('shows preview settings and persists changes independently of preview inputs', function () {
+ Livewire::test(Previews::class, ['application' => $this->application])
+ ->assertSee('PR deployment access')
+ ->assertSet('isPreviewDeploymentsEnabled', false)
+ ->set('manualPullRequestId', -1)
+ ->set('isPreviewDeploymentsEnabled', true)
+ ->set('isPrDeploymentsPublicEnabled', true)
+ ->call('savePreviewSettings')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($this->application->fresh()->settings)
+ ->is_preview_deployments_enabled->toBeTrue()
+ ->is_pr_deployments_public_enabled->toBeTrue();
+
+ Livewire::test(Previews::class, ['application' => $this->application->fresh()])
+ ->assertSet('isPreviewDeploymentsEnabled', true)
+ ->assertSet('isPrDeploymentsPublicEnabled', true)
+ ->set('isPreviewDeploymentsEnabled', false)
+ ->call('savePreviewSettings');
+
+ expect($this->application->fresh()->settings->is_preview_deployments_enabled)->toBeFalse();
+});
+
+it('does not show git preview settings for non-git applications', function (string $buildPack, ?string $dockerfile) {
+ $this->application->update(['build_pack' => $buildPack, 'dockerfile' => $dockerfile]);
+
+ Livewire::test(Previews::class, ['application' => $this->application->fresh()])
+ ->assertDontSee('PR deployment access');
+})->with([['dockerimage', null], ['dockerfile', 'FROM nginx']]);
+
+it('denies preview setting changes without application update permission', function (string $role, bool $otherTeam) {
+ $user = User::factory()->create();
+ $team = $otherTeam ? Team::factory()->create() : $this->team;
+ $team->members()->attach($user->id, ['role' => $role]);
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ Livewire::test(Previews::class, ['application' => $this->application])
+ ->set('isPreviewDeploymentsEnabled', true)
+ ->set('isPrDeploymentsPublicEnabled', true)
+ ->call('savePreviewSettings')
+ ->assertForbidden();
+
+ expect($this->application->fresh()->settings)
+ ->is_preview_deployments_enabled->toBeFalse()
+ ->is_pr_deployments_public_enabled->toBeFalse();
+})->with([['member', false], ['owner', true]]);
+
+it('removes preview settings from Advanced including its persistence path', function () {
+ expect(file_get_contents(resource_path('views/livewire/project/application/advanced.blade.php')))
+ ->not->toContain('isPreviewDeploymentsEnabled', 'isPrDeploymentsPublicEnabled');
+ expect(file_get_contents(app_path('Livewire/Project/Application/Advanced.php')))
+ ->not->toContain('is_preview_deployments_enabled', 'is_pr_deployments_public_enabled');
+});
diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php
index c93a4f1e9..6b47a085a 100644
--- a/tests/Feature/ServiceDomainsTest.php
+++ b/tests/Feature/ServiceDomainsTest.php
@@ -12,6 +12,7 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
@@ -20,6 +21,7 @@
beforeEach(function () {
$this->withoutVite();
+ config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
@@ -140,24 +142,22 @@
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
- ->toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"")
- ->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"")
->toContain('src="https://api.example.com/favicon.ico"')
->toContain('class="relative size-4 shrink-0"')
->toContain('domain-favicon-fallback')
->toContain('class="invisible absolute inset-0 size-4 rounded-sm"')
->toContain('$el.previousElementSibling.classList.add(\'hidden\')')
->toContain('x-on:error="$el.remove()"')
- ->toContain('class="min-w-0 flex-1 text-[13px]')
+ ->toContain('class="min-w-0 flex-1 truncate text-[13px]')
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->toContain('dark:bg-white/[0.04]')
->toContain('Domain')
- ->toContain('DNS Check')
+ ->toContain('DNS status')
->not->toContain('Last checked')
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
- ->and(strpos($html, '>API'))->toBeLessThan(strpos($html, 'Domain'))
+ ->and(substr_count($html, 'Domain'))->toBe(1)
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
@@ -194,16 +194,25 @@
->assertDontSee('Redirect HTTP to HTTPS');
});
-it('shows one redirect control for each www and non-www pair', function () {
- $this->apiApp->update([
- 'fqdn' => 'https://api.example.com,https://www.api.example.com,https://admin.example.com,https://www.admin.example.com',
- ]);
+it('opens address fields and service-wide redirects in the same settings dialog for every domain', function () {
+ $domains = ['https://api.example.com', 'https://www.api.example.com', 'https://admin.example.com', 'https://www.admin.example.com'];
+ $this->apiApp->update(['fqdn' => implode(',', $domains)]);
- $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
- ->assertSuccessful()
- ->html();
+ $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]);
- expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(2);
+ foreach ($domains as $index => $domain) {
+ $html = $component->call('startEdit', $index)
+ ->assertSet('editingDomain', $domain)
+ ->assertSee('Domain settings')
+ ->assertSee('Save changes')
+ ->assertDontSee('Save address')
+ ->assertSee('Search engine indexing')
+ ->assertSee('www redirect')
+ ->assertDontSee('Edit address and port')
+ ->html();
+
+ expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1);
+ }
});
it('uses segmented fields when adding and editing service domains', function () {
@@ -246,12 +255,12 @@
->assertSee('Manual records');
});
-it('rotates the dns entries chevron while its dropdown is open', function () {
+it('exposes the dns entries dropdown expanded state', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php'));
expect($view)
- ->toContain('class="inline-flex transition-transform"')
- ->toContain(':class="dnsEntriesOpen && \'rotate-180\'"');
+ ->toContain('x-bind:aria-expanded="dnsEntriesOpen"')
+ ->toContain('x-show="dnsEntriesOpen"');
});
it('lists dns entries for service hosts that still need dns', function () {
@@ -418,7 +427,7 @@
expect($view)
->toContain('wire:key="service-domains-list"')
- ->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"')
+ ->toContain('wire:key="service-domain-rows-{{ $appId }}"')
->not->toContain('md5(serialize($domainRows))');
});
@@ -502,9 +511,9 @@
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
- ->assertSee('Direction')
+ ->assertSee('www redirect')
->assertSee('Search engine indexing')
- ->set('editingDomain', 'https://renamed.example.com')
+ ->set('editingDomainParts.host', 'renamed.example.com')
->call('updateDomain')
->assertHasNoErrors()
->assertDispatched('edit-domain-saved')
@@ -757,7 +766,8 @@
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');
+ ->assertCount('domainRows', 2)
+ ->assertSet('domainRows.1.url', 'https://healthy.example.com');
});
it('hides dns message text when service domain dns status is ok', function () {
@@ -773,7 +783,7 @@
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
- ->assertSee('DNS OK')
+ ->assertSee('DNS matches')
->assertDontSee('DNS points to 203.0.113.10');
});
@@ -827,7 +837,8 @@
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
- ->assertDontSee('Recheck DNS')
+ ->assertDontSee('Check all DNS')
+ ->assertDontSee('aria-label="Settings for', false)
->assertDontSee('Check DNS');
});
@@ -846,10 +857,11 @@
it('updates search engine indexing from the service domains view', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('startEdit', 0)
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('Search engine indexing')
- ->assertSee('Direction')
+ ->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateServiceRedirect', false)
->assertSee('wire:ignore', false)
@@ -931,3 +943,169 @@
->assertDontSee('Internal port ')
->assertDontSee('table-badge-danger', false);
});
+
+it('prioritizes public addresses and moves domain configuration behind settings', function () {
+ $this->apiApp->update(['fqdn' => 'https://api.example.com:8080']);
+
+ $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->assertSee('Check all DNS')
+ ->assertSee('Add domain')
+ ->assertSee('Domain settings')
+ ->call('startEdit', 0)
+ ->assertSee('Indexing and redirect changes save automatically.')
+ ->assertSee('Internal port 8080')
+ ->assertSee('Both www and non-www')
+ ->assertSee('Search indexing allowed')
+ ->assertDontSee('Manage domains and www/non-www redirects')
+ ->html();
+
+ expect($html)->toContain('title="https://api.example.com"')
+ ->not->toContain('title="https://api.example.com:8080"');
+});
+
+it('distinguishes unchecked domains from dns checks in progress', function () {
+ InstanceSettings::find(0)->update(['is_dns_validation_enabled' => true]);
+ Cache::forget('instance_settings');
+
+ Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->assertSee('Not checked')
+ ->assertDontSee('DNS pending');
+});
+
+it('keeps the edited domain selected when settings refresh and reorder rows', function () {
+ $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('startEdit', 0);
+
+ $this->webApp->update([
+ 'fqdn' => 'https://broken.example.com',
+ 'domain_dns_statuses' => [
+ 'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
+ ],
+ ]);
+
+ $component->call('refreshDomains')
+ ->assertSet('editingIndex', 1)
+ ->set('editingDomainParts.host', 'renamed.example.com')
+ ->call('updateDomain')
+ ->assertHasNoErrors();
+
+ expect($this->apiApp->fresh()->fqdn)->toBe('https://renamed.example.com')
+ ->and($this->webApp->fresh()->fqdn)->toBe('https://broken.example.com');
+});
+
+it('renders compact icon-only domain actions with accessible labels', function () {
+ $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])->html();
+ $document = new DOMDocument;
+ @$document->loadHTML($html);
+ $xpath = new DOMXPath($document);
+
+ foreach (['Check DNS', 'Settings for https://api.example.com', 'Remove domain'] as $label) {
+ $buttons = $xpath->query('//button[@aria-label="'.$label.'"]');
+ expect($buttons->length)->toBe(1);
+ $button = $buttons->item(0);
+ expect(trim($button->textContent))->toBe('')
+ ->and($button->getAttribute('class'))->toContain('icon-button')
+ ->and($button->getAttribute('title'))->not->toBe('');
+ }
+
+ expect($html)->not->toContain('aria-label="More actions for');
+});
+
+it('reuses the floating save bar for pending domain address edits', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
+
+ expect($view)->toContain('toContain('dirty="hasAddressChanges"')
+ ->toContain('')
+ ->not->toContain('Save address');
+});
+
+it('inherits the counterpart internal port when enabling redirects without a port warning', function (?int $override, string $redirect) {
+ $this->service->update([
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_URL_WEB_80\n api:\n image: node:alpine\n",
+ ]);
+ $host = $redirect === 'www' ? 'web.example.com' : 'www.web.example.com';
+ $counterpart = $redirect === 'www' ? 'www.web.example.com' : 'web.example.com';
+ $url = "https://{$host}/blog";
+ $pairedUrl = "https://{$counterpart}/blog";
+ $this->webApp->update([
+ 'fqdn' => $url,
+ 'redirect' => 'both',
+ 'domain_port_overrides' => $override === null ? null : [$url => $override],
+ ]);
+
+ Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('updateServiceRedirect', $this->webApp->id, $redirect)
+ ->assertHasNoErrors()
+ ->assertSet('showPortWarningModal', false)
+ ->assertSet('pendingAction', null)
+ ->assertDispatched('success', 'Redirect updated.')
+ ->call('refreshDomains')
+ ->assertSet("serviceRedirects.{$this->webApp->id}", $redirect);
+
+ $this->webApp->refresh();
+ expect($this->webApp->redirect)->toBe($redirect)
+ ->and($this->webApp->fqdn)->toContain($pairedUrl)
+ ->and($this->webApp->domain_port_overrides[$pairedUrl] ?? $this->webApp->getRequiredPort())->toBe($override ?? 80)
+ ->and($this->webApp->domain_port_overrides[$url] ?? null)->toBe($override);
+})->with([null, 80, 8080])->with(['www', 'non-www']);
+
+it('still warns and allows cancellation when manually adding a different port', function () {
+ $this->service->update([
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB_8000\n api:\n image: node:alpine\n",
+ ]);
+
+ Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->set('newServiceApplicationId', $this->webApp->id)
+ ->set('newDomain', 'https://web.example.com:3000')
+ ->call('addDomain')
+ ->assertSet('showPortWarningModal', true)
+ ->call('cancelRemovePort')
+ ->assertSet('showPortWarningModal', false)
+ ->assertSet('pendingAction', null)
+ ->call('addDomain')
+ ->assertSet('showPortWarningModal', true);
+
+ expect($this->webApp->fresh()->fqdn)->toBeNull();
+});
+
+it('still checks domain conflicts when inheriting a redirect counterpart port', function () {
+ $this->webApp->update([
+ 'fqdn' => 'https://example.com',
+ 'redirect' => 'both',
+ 'domain_port_overrides' => ['https://example.com' => 8080],
+ ]);
+ $this->apiApp->update(['fqdn' => 'https://www.example.com']);
+
+ $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('updateServiceRedirect', $this->webApp->id, 'www')
+ ->assertSet('showDomainConflictModal', true)
+ ->assertSet('showPortWarningModal', false);
+
+ expect($this->webApp->fresh()->redirect)->toBe('both');
+
+ $component->call('refreshDomains')
+ ->call('confirmDomainUsage')
+ ->assertSet('showDomainConflictModal', false)
+ ->assertSet('showPortWarningModal', false)
+ ->assertDispatched('success', 'Redirect updated.');
+
+ expect($this->webApp->fresh()->redirect)->toBe('www')
+ ->and($this->webApp->fresh()->domain_port_overrides['https://www.example.com'])->toBe(8080);
+});
+
+it('renders domain settings in compact columns instead of a second summary line', function () {
+ $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])->html();
+ foreach (['Protocol redirect', 'Domain redirect', 'Internal port', 'Search indexing'] as $heading) {
+ expect($html)->toContain(''.$heading.'');
+ }
+ $view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
+ expect($view)->toContain('service-domain-detail')
+ ->not->toContain('gap-x-3 gap-y-1');
+});
+
+it('lays out the domain settings dropdowns in responsive columns', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
+ expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')
+ ->toContain('class="sm:col-span-2 text-[12px]');
+});
diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php
index 161f96f66..bd9084d3f 100644
--- a/tests/v4/Browser/ApplicationConfigurationTest.php
+++ b/tests/v4/Browser/ApplicationConfigurationTest.php
@@ -1,7 +1,9 @@
assertSee('Config App')
->screenshot(filename: 'application-danger-zone');
});
+
+it('uses compact application domains with unified settings and a floating save bar', function () {
+ config()->set('app.maintenance.store', 'array');
+ InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
+ Cache::forget('instance_settings');
+ $this->application->update(['fqdn' => 'https://first.example.com,https://second.example.com', 'redirect' => 'both']);
+ loginAndSkipBoarding();
+ $url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/domains';
+ $page = visit($url);
+ $page->click('Accept and close')
+ ->assertSee('Check all DNS')
+ ->assertSee('Protocol redirect')
+ ->assertSee('Search indexing')
+ ->assertDontSee('Search engine indexing')
+ ->fill('[aria-label="Search services or domains"]', 'missing.example.com')
+ ->assertSee('No domains found')
+ ->fill('[aria-label="Search services or domains"]', '')
+ ->click('[aria-label="Settings for https://first.example.com"]')
+ ->assertSee('Domain settings')
+ ->assertValue('#editingDomainParts-host', 'first.example.com')
+ ->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
+ ->fill('#editingDomainParts-path', '/blog')
+ ->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
+ ->click('[id^="application-domain-indexing-"][id$="-trigger"]')
+ ->click('Noindex')
+ ->assertSee('Search engine indexing updated.')
+ ->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
+ ->screenshot(filename: 'application-domain-unified-settings')
+ ->click('[wire\\:click="updateDomain"]')
+ ->assertDontSee('Domain settings')
+ ->assertSee('https://first.example.com/blog')
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'application-domains-compact');
+
+ $page->click('[aria-label="Settings for https://second.example.com"]')
+ ->fill('#editingDomainParts-path', '/discard')
+ ->click('Reset')
+ ->assertDontSee('Domain settings')
+ ->click('[aria-label="Settings for https://second.example.com"]')
+ ->assertValue('#editingDomainParts-path', '')
+ ->click('[aria-label="Close"]:visible')
+ ->click('[wire\\:key="domain-row-'.md5('https://first.example.com/blog|').'"] [aria-label="Remove domain"]')
+ ->assertSee('Remove domain?')
+ ->click('button:has([x-text="step2ButtonText"]):visible')
+ ->assertDontSee('https://first.example.com/blog')
+ ->click('[aria-label="Settings for https://second.example.com"]')
+ ->assertValue('#editingDomainParts-host', 'second.example.com')
+ ->click('[aria-label="Close"]:visible')
+ ->resize(390, 844)
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'application-domains-mobile');
+ expect($page->script('document.documentElement.scrollWidth <= window.innerWidth'))->toBeTrue();
+});
+
+it('edits Compose application domain redirects in the unified settings dialog', function () {
+ config()->set('app.maintenance.store', 'array');
+ InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
+ Cache::forget('instance_settings');
+ $this->application->update([
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => "services:\n web.api:\n image: nginx:alpine\n expose:\n - '8080'\n",
+ 'docker_compose_domains' => json_encode(['web.api' => ['domain' => 'https://web.example.com', 'redirect' => 'both']]),
+ ]);
+ loginAndSkipBoarding();
+ $url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/domains';
+ $page = visit($url);
+ $page->click('Accept and close')
+ ->assertSee('web.api')
+ ->assertSee('Domain redirect')
+ ->click('[aria-label="Settings for https://web.example.com"]')
+ ->assertSee('Domain settings')
+ ->click('[id^="application-domain-direction-"][id$="-trigger"]')
+ ->click('Redirect to www')
+ ->assertDontSee('Use a different port?')
+ ->assertSee('Redirect updated for web.api.')
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'application-compose-domain-settings');
+
+ expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
+ $page->click('[aria-label="Close"]:visible')
+ ->assertSee('https://www.web.example.com')
+ ->screenshot(filename: 'application-compose-domain-overview');
+});
diff --git a/tests/v4/Browser/ServiceConfigurationTest.php b/tests/v4/Browser/ServiceConfigurationTest.php
index d5c840ae6..6f7919664 100644
--- a/tests/v4/Browser/ServiceConfigurationTest.php
+++ b/tests/v4/Browser/ServiceConfigurationTest.php
@@ -1,6 +1,8 @@
assertSee('Config Service')
->screenshot(filename: 'service-danger-zone');
});
+
+it('keeps domain settings out of the overview and supports editing and removal', function () {
+ config()->set('app.maintenance.store', 'array');
+ InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
+ Cache::forget('instance_settings');
+ $this->serviceApplication->update([
+ 'fqdn' => 'https://long-public-domain-for-the-service.example.com,https://second.example.com',
+ 'domain_port_overrides' => ['https://long-public-domain-for-the-service.example.com' => 8080],
+ ]);
+ loginAndSkipBoarding();
+
+ $url = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->service).'/domains';
+ $page = visit($url);
+ $page->click('Accept and close')
+ ->assertSee('Check all DNS')
+ ->assertVisible('[aria-label="Internal port 8080"]')
+ ->assertDontSee('Search engine indexing')
+ ->assertDontSee('Remove domain')
+ ->screenshot(filename: 'service-domains-overview');
+
+ expect($page->script("document.querySelector('.data-table-row.service-domains-overview-grid').getBoundingClientRect().height"))->toBeLessThanOrEqual(48);
+ expect($page->script("getComputedStyle(document.querySelector('.data-table-row.service-domains-overview-grid')).gridTemplateColumns.split(' ').length"))->toBe(7);
+
+ $page->click('[aria-label="Settings for https://long-public-domain-for-the-service.example.com"]')
+ ->assertSee('Domain settings')
+ ->assertValue('#editingDomainParts-host', 'long-public-domain-for-the-service.example.com')
+ ->assertValue('#editingDomainParts-port', '8080')
+ ->assertDontSee('Edit address and port')
+ ->assertDontSee('Save address')
+ ->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
+ ->assertSee('Search engine indexing')
+ ->screenshot(filename: 'service-domain-settings');
+
+ expect($page->script(<<<'JS'
+ (() => {
+ const indexing = document.querySelector('[id^="service-domain-indexing-"][id$="-trigger"]').getBoundingClientRect();
+ const redirect = document.querySelector('[id^="service-domain-direction-"][id$="-trigger"]').getBoundingClientRect();
+ return Math.abs(indexing.top - redirect.top) < 2 && redirect.left > indexing.right;
+ })()
+ JS))->toBeTrue();
+
+ $page->fill('#editingDomainParts-port', '80')
+ ->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
+ ->screenshot(filename: 'service-domain-unsaved-changes');
+
+ $domainKey = hash('sha256', 'https://long-public-domain-for-the-service.example.com|'.$this->serviceApplication->id);
+ $page->click('#service-domain-indexing-'.$this->serviceApplication->id.'-'.$domainKey.'-trigger')
+ ->click('Noindex')
+ ->screenshot(filename: 'service-domain-indexing-saved')
+ ->assertSee('Domain settings')
+ ->assertVisible('[aria-label="Search indexing blocked"]')
+ ->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
+ ->assertNoJavaScriptErrors();
+
+ expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
+
+ $page->click('[wire\\:click="updateDomain"]')
+ ->assertDontSee('Domain settings')
+ ->assertVisible('[aria-label="Internal port 80"] >> nth=0');
+
+ $page->click('[wire\\:key="svc-domain-'.$this->serviceApplication->id.'-'.md5('https://long-public-domain-for-the-service.example.com').'"] [aria-label="Remove domain"]')
+ ->assertSee('Remove domain?')
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'service-domain-removal-confirmation')
+ ->click('button:has([x-text="step2ButtonText"]):visible')
+ ->assertDontSee('https://long-public-domain-for-the-service.example.com')
+ ->assertSee('1 domain across 1 service')
+ ->click('[aria-label="Settings for https://second.example.com"]')
+ ->assertSee('www redirect')
+ ->assertValue('#editingDomainParts-host', 'second.example.com')
+ ->click('[aria-label="Close"]:visible')
+ ->assertDontSee('Domain settings')
+ ->assertNoJavaScriptErrors();
+
+ $page->click('[aria-label="Settings for https://second.example.com"]')
+ ->fill('#editingDomainParts-path', '/discard-this')
+ ->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
+ ->click('Reset')
+ ->assertDontSee('Domain settings')
+ ->click('[aria-label="Settings for https://second.example.com"]')
+ ->assertValue('#editingDomainParts-path', '')
+ ->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
+ ->click('[aria-label="Close"]:visible')
+ ->assertDontSee('Domain settings')
+ ->assertNoJavaScriptErrors();
+
+ $page->script("document.querySelectorAll('[aria-label=\"Dismiss\"]').forEach(button => button.click()); document.documentElement.classList.remove('dark');");
+ $page->screenshot(filename: 'service-domains-light');
+ $page->resize(390, 844)
+ ->assertSee('https://second.example.com')
+ ->assertVisible('[aria-label="Settings for https://second.example.com"]');
+ $page->script("document.getElementById('service-domains-section').scrollIntoView(); window.scrollBy(0, -80);");
+ $page->screenshot(filename: 'service-domains-mobile');
+
+ expect($page->script('document.documentElement.scrollWidth <= window.innerWidth'))->toBeTrue();
+});
+
+it('inherits the internal port when enabling the www redirect without a warning', function (?int $override) {
+ config()->set('app.maintenance.store', 'array');
+ InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
+ Cache::forget('instance_settings');
+ $this->service->update([
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB_80\n api:\n image: httpd:alpine\n",
+ ]);
+ $this->serviceApplication->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both', 'domain_port_overrides' => $override === null ? null : ['https://web.example.com' => $override]]);
+ loginAndSkipBoarding();
+
+ $url = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->service).'/domains';
+ $page = visit($url);
+ $page->click('Accept and close')
+ ->click('[aria-label="Settings for https://web.example.com"]')
+ ->click('[id^="service-domain-direction-"][id$="-trigger"]')
+ ->click('Redirect to www')
+ ->assertDontSee('Use a different port?')
+ ->assertSee('Redirect updated.')
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'service-redirect-port-'.($override ?? 80));
+
+ expect($this->serviceApplication->fresh()->redirect)->toBe('www')
+ ->and($this->serviceApplication->fresh()->domain_port_overrides['https://www.web.example.com'] ?? null)->toBe($override);
+
+ $page->navigate($url)
+ ->click('[aria-label="Settings for https://web.example.com"]')
+ ->assertSee('Redirect to www')
+ ->assertNoJavaScriptErrors()
+ ->screenshot(filename: 'service-redirect-persisted-'.($override ?? 80));
+})->with([null, 8080]);