diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 0bb6d9240..a933b9805 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -179,6 +179,11 @@ class Domains extends Component return $this->normalizeRedirect($this->serviceRedirects[$serviceApplicationId] ?? null); } + public function updatedServiceRedirects(string $redirect, int|string $serviceApplicationId): void + { + $this->updateServiceRedirect((int) $serviceApplicationId, $redirect); + } + /** * @return array> */ @@ -583,6 +588,12 @@ class Domains extends Component }; } + public function updateServiceRedirect(int $serviceApplicationId, string $redirect): void + { + $this->serviceRedirects[$serviceApplicationId] = $redirect; + $this->setServiceRedirect($serviceApplicationId); + } + /** * @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.) */ @@ -634,10 +645,9 @@ class Domains extends Component $this->forceSaveDomains = false; $this->forceRemovePort = false; $this->dispatch('success', 'Redirect updated.'); - $this->dispatch('refresh'); - $this->dispatch('refreshServices'); $this->dispatch('configurationChanged'); $this->pruneDomainDnsStatusesToCurrentDomains(); + $this->refreshDomains(); } catch (\Throwable $e) { handleError($e, $this); } @@ -825,11 +835,13 @@ class Domains extends Component } $newUrls = $this->splitDomains($normalized); - $pairedUrls = collect($newUrls) - ->map(fn (string $url) => $this->wwwCounterpartUrl($url)) - ->filter() - ->values() - ->all(); + $pairedUrls = in_array($this->normalizeRedirect($app->redirect), ['www', 'non-www'], true) + ? collect($newUrls) + ->map(fn (string $url) => $this->wwwCounterpartUrl($url)) + ->filter() + ->values() + ->all() + : []; $current = collect($this->splitDomains($app->fqdn)); foreach ($newUrls as $url) { if ($current->contains($url)) { @@ -990,6 +1002,7 @@ class Domains extends Component $this->forceRemovePort = false; $this->dispatch('success', 'Domain removed.'); $this->pruneDomainDnsStatusesToCurrentDomains(); + $this->refreshDomains(); } catch (\Throwable $e) { handleError($e, $this); } @@ -1156,8 +1169,6 @@ class Domains extends Component $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); } - $this->dispatch('refresh'); - $this->dispatch('refreshServices'); $this->dispatch('configurationChanged'); return true; diff --git a/resources/css/app.css b/resources/css/app.css index 826e5470a..24e45d24a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -352,6 +352,26 @@ tr td:first-child { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); } +/* Loading feedback uses the yellow brand accent consistently in dark mode. */ +.dark .animate-spin { + color: var(--color-warning) !important; +} + +.dark #nprogress .bar { + background: var(--color-warning) !important; +} + +.dark #nprogress .peg { + box-shadow: + 0 0 10px var(--color-warning), + 0 0 5px var(--color-warning) !important; +} + +.dark #nprogress .spinner-icon { + border-top-color: var(--color-warning) !important; + border-left-color: var(--color-warning) !important; +} + .log-highlight { background-color: rgba(234, 179, 8, 0.4); border-radius: 2px; @@ -409,7 +429,7 @@ tr td:first-child { .application-console-shell { box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 0.08), + inset 0 0 0 1px var(--console-theme-border, rgb(255 255 255 / 0.08)), 0 18px 50px rgb(0 0 0 / 0.18); } @@ -436,12 +456,20 @@ tr td:first-child { .application-console-shell[data-console-theme="system"], .terminal-fullscreen-shell[data-console-theme="system"] { --console-theme-background: #fff; + --console-theme-border: #d4d4d8; --console-theme-opacity: 1; } html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header { + background: transparent; color: #52525b; border-color: rgb(0 0 0 / 0.1); + backdrop-filter: none; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-block::before { + background: transparent; + backdrop-filter: none; } html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] { @@ -451,6 +479,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .applica html.dark .application-console-shell[data-console-theme="system"], html.dark .terminal-fullscreen-shell[data-console-theme="system"] { --console-theme-background: #121214; + --console-theme-border: rgb(255 255 255 / 0.08); } .console-theme-selector { diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 6f139cd21..ac5593d16 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -139,7 +139,7 @@ } @utility loading-indicator { - @apply text-coollabs dark:text-coollabs-100; + @apply text-coollabs dark:text-warning; } /* Compact icon-only control (gear, chevrons, etc.) */ diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 1f0581b8c..50e74baf5 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -5,21 +5,17 @@
+ @mouseenter="show()" @mouseleave="hide" @focus="show()" @blur="closeWhenFocusLeaves()" + @click.prevent.stop="show()"> @isset($trigger) {{ $trigger }} @elseif (isset($icon)) @@ -93,20 +88,18 @@ aria-hidden="true" /> @endisset - +
diff --git a/resources/views/components/server-timing-hud.blade.php b/resources/views/components/server-timing-hud.blade.php index bf3217050..45f6d4500 100644 --- a/resources/views/components/server-timing-hud.blade.php +++ b/resources/views/components/server-timing-hud.blade.php @@ -1,29 +1,81 @@ {{-- Dev-only Server-Timing HUD. Injected by AddServerTimingHeaders on full HTML responses. JS docks into the top bar: #server-timing-hud-slot (lg+) or #server-timing-hud-slot-mobile ( + #server-timing-hud { + --sth-background: rgba(255, 255, 255, .96); + --sth-text: #18181b; + --sth-strong: #09090b; + --sth-secondary: #52525b; + --sth-muted: #71717a; + --sth-border: rgba(0, 0, 0, .14); + --sth-surface: rgba(0, 0, 0, .035); + --sth-shadow: 0 12px 32px rgba(0, 0, 0, .18); + --sth-scrollbar-track: rgba(0, 0, 0, .06); + --sth-scrollbar-thumb: #a1a1aa; + --sth-livewire: #0284c7; + --sth-xhr: #9333ea; + --sth-document: #4d7c0f; + } + + html.dark #server-timing-hud { + --sth-background: rgba(16, 16, 16, .96); + --sth-text: #e5e5e5; + --sth-strong: #fafafa; + --sth-secondary: #a3a3a3; + --sth-muted: #737373; + --sth-border: rgba(255, 255, 255, .12); + --sth-surface: rgba(255, 255, 255, .04); + --sth-shadow: 0 12px 32px rgba(0, 0, 0, .4); + --sth-scrollbar-track: rgba(255, 255, 255, .06); + --sth-scrollbar-thumb: #71717a; + --sth-livewire: #38bdf8; + --sth-xhr: #c084fc; + --sth-document: #a3e635; + } + + #server-timing-hud [data-sth-log] { + scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track); + scrollbar-width: thin; + } + + #server-timing-hud [data-sth-log]::-webkit-scrollbar { + width: 8px; + } + + #server-timing-hud [data-sth-log]::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: var(--sth-scrollbar-thumb); + } + + #server-timing-hud [data-sth-log]::-webkit-scrollbar-track { + border-radius: 9999px; + background: var(--sth-scrollbar-track); + } +
+ style="position:fixed;bottom:12px;left:12px;z-index:2147483000;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:var(--sth-text);pointer-events:auto">
@@ -84,7 +136,7 @@ 'opacity:1', isMobileSlot ? 'margin-right:4px' : 'margin-right:8px', 'font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace', - 'color:#e5e5e5', + 'color:var(--sth-text)', 'pointer-events:auto', ].join(';'); @@ -136,7 +188,7 @@ 'visibility:visible', 'opacity:1', 'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace', - 'color:#e5e5e5', + 'color:var(--sth-text)', 'pointer-events:auto', ].join(';'); @@ -354,12 +406,12 @@ function kindColor(kind) { if (kind === 'livewire') { - return '#38bdf8'; + return 'var(--sth-livewire)'; } if (kind === 'xhr') { - return '#c084fc'; + return 'var(--sth-xhr)'; } - return '#a3e635'; + return 'var(--sth-document)'; } function formatTime(ts) { @@ -509,7 +561,7 @@ card.style.outline = prev || ''; if (hint) { hint.textContent = 'Click to copy'; - hint.style.color = '#737373'; + hint.style.color = 'var(--sth-muted)'; } }, 1200); } @@ -585,7 +637,7 @@ if (countEl) { countEl.textContent = '0 requests'; } - log.innerHTML = '
No requests yet
'; + log.innerHTML = '
No requests yet
'; return; } @@ -617,10 +669,10 @@ card.style.width = '100%'; card.style.textAlign = 'left'; card.style.cursor = 'pointer'; - card.style.border = '1px solid rgba(255,255,255,.08)'; + card.style.border = '1px solid var(--sth-border)'; card.style.borderRadius = '8px'; card.style.padding = '8px'; - card.style.background = index === 0 ? 'rgba(255,255,255,.04)' : 'transparent'; + card.style.background = index === 0 ? 'var(--sth-surface)' : 'transparent'; card.style.color = 'inherit'; card.style.font = 'inherit'; @@ -640,18 +692,18 @@ const time = document.createElement('span'); time.textContent = formatTime(entry.at); - time.style.color = '#737373'; + time.style.color = 'var(--sth-muted)'; time.style.fontSize = '10px'; const method = document.createElement('span'); method.textContent = entry.method; - method.style.color = '#a3a3a3'; + method.style.color = 'var(--sth-secondary)'; method.style.fontSize = '10px'; const path = document.createElement('span'); path.textContent = entry.path; path.title = entry.path; - path.style.color = '#e5e5e5'; + path.style.color = 'var(--sth-text)'; path.style.fontSize = '10px'; path.style.overflow = 'hidden'; path.style.textOverflow = 'ellipsis'; @@ -663,7 +715,7 @@ copyHint.setAttribute('data-sth-copy-hint', ''); copyHint.textContent = 'Click to copy'; copyHint.style.fontSize = '10px'; - copyHint.style.color = '#737373'; + copyHint.style.color = 'var(--sth-muted)'; copyHint.style.marginLeft = 'auto'; head.appendChild(kind); @@ -699,10 +751,10 @@ row.style.gap = '8px'; const k = document.createElement('span'); k.textContent = labels[name] || name; - k.style.color = '#737373'; + k.style.color = 'var(--sth-muted)'; const v = document.createElement('span'); v.textContent = formatValue(name, entry.metrics[name]); - v.style.color = '#fafafa'; + v.style.color = 'var(--sth-strong)'; if (name === 'app' && Number(entry.metrics[name]) >= 500) { v.style.color = '#fbbf24'; } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 56a6e4f79..69c619d9c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -50,7 +50,7 @@ @endif {{-- Collapse toggle + team switcher --}} -
+
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 650865721..94b03e6b3 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -14,7 +14,7 @@ - + diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 87804ce1a..6da2a6b07 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -3,9 +3,9 @@ $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; $serviceAppCount = count($serviceApps); - $isSingleService = $serviceAppCount === 1; - $singleApp = $isSingleService ? collect($serviceApps)->first() : null; - $singleAppId = $singleApp['id'] ?? null; + $domainGroups = collect($domainRows) + ->groupBy('service_application_id') + ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); @endphp
- + @can('update', $service) @endcannot - @if ($isSingleService && $singleAppId) -
-
- - - - - -
- @can('update', $service) -
- -
- @endcan -
- @elseif ($serviceAppCount > 1) -

- Domains are listed with their service name. Set each service redirect from the group controls under the table when needed. -

- @endif +

+ Manage domains and www/non-www redirects for applications in this stack. +

+
{{-- Toolbar --}} @@ -175,60 +149,61 @@ icon-name="globe" />
@else - {{-- Flat table with Service column so assignment is always visible --}} -
- @include('livewire.project.service.partials.domain-table', [ - 'rows' => collect($domainRows), - 'domainRows' => $domainRows, - 'service' => $service, - 'showServiceColumn' => true, - ]) -
- - {{-- Multi-service redirect controls under the table --}} - @if ($serviceAppCount > 1) -
- @foreach ($serviceApps as $app) - @php - $appId = $app['id']; - $heading = $app['name'] ?? 'Service'; - $appDomainCount = collect($domainRows) - ->where('service_application_id', $appId) - ->where('is_suggested', false) - ->count(); - @endphp - -
-
- +
+ @foreach ($domainGroups as $appId => $rows) + @php + $app = collect($serviceApps)->firstWhere('id', (int) $appId); + $heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service'); + $appDomainCount = $rows->where('is_suggested', false)->count(); + $redirect = $serviceRedirects[$appId] ?? 'both'; + $redirectLabel = match ($redirect) { + 'www' => 'Redirect to www', + 'non-www' => 'Redirect to non-www', + default => 'Allow both', + }; + @endphp +
+
+ {{ $heading }} + + @can('update', $service) +
+ {{ $redirectLabel }} + + +
- @can('update', $service) -
- -
- @endcan -
- - @endforeach -
- @endif + @else + {{ $redirectLabel }} + @endcan +
+ +
+ @include('livewire.project.service.partials.domain-table', [ + 'rows' => $rows, + 'domainRows' => $domainRows, + 'service' => $service, + 'showServiceColumn' => false, + 'showHeader' => false, + ]) +
+ + @endforeach +
@endif {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}} diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 84805935a..6f3236b7d 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -1,18 +1,21 @@ @php $showServiceColumn = $showServiceColumn ?? false; + $showHeader = $showHeader ?? true; $gridClass = $showServiceColumn ? 'domains-table-grid-compose' : 'domains-table-grid'; @endphp
-
- Domain - @if ($showServiceColumn) - Service - @endif - DNS - Last checked - -
+ @if ($showHeader) +
+ Domain + @if ($showServiceColumn) + Service + @endif + DNS + Last checked + +
+ @endif @foreach ($rows as $row) @php $index = collect($domainRows)->search( diff --git a/resources/views/livewire/server/docker-cleanup-executions.blade.php b/resources/views/livewire/server/docker-cleanup-executions.blade.php index 9e13ab231..95d30453e 100644 --- a/resources/views/livewire/server/docker-cleanup-executions.blade.php +++ b/resources/views/livewire/server/docker-cleanup-executions.blade.php @@ -12,6 +12,8 @@ @forelse($executions as $execution)
@if (data_get($execution, 'id') == $selectedKey) @@ -57,7 +62,7 @@
@if ($this->logLines->isNotEmpty()) -
@foreach ($this->logLines as $line)
+                        
@foreach ($this->logLines as $line)
 {{ $line }}
 @endforeach
@if ($this->hasMoreLogs()) diff --git a/tests/Feature/AddServerTimingHeadersTest.php b/tests/Feature/AddServerTimingHeadersTest.php index 2c183a33a..d2e81adc6 100644 --- a/tests/Feature/AddServerTimingHeadersTest.php +++ b/tests/Feature/AddServerTimingHeadersTest.php @@ -131,6 +131,20 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu ->toContain('compactSummary'); }); +test('Server-Timing HUD follows the application color mode', function () { + $hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php')); + + expect($hud) + ->toContain('#server-timing-hud {') + ->toContain('html.dark #server-timing-hud {') + ->toContain('--sth-background: rgba(255, 255, 255, .96)') + ->toContain('--sth-background: rgba(16, 16, 16, .96)') + ->toContain('var(--sth-text)') + ->toContain('var(--sth-border)') + ->toContain('[data-sth-log]::-webkit-scrollbar-thumb') + ->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)'); +}); + test('does not inject HUD into non-HTML or fragment responses', function () { Config::set('app.server_timing', true); diff --git a/tests/Feature/BreadcrumbSelectionStyleTest.php b/tests/Feature/BreadcrumbSelectionStyleTest.php index e582240fa..b44565e60 100644 --- a/tests/Feature/BreadcrumbSelectionStyleTest.php +++ b/tests/Feature/BreadcrumbSelectionStyleTest.php @@ -12,3 +12,11 @@ test('breadcrumb switchers indicate the current item with background only', func ->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100'))) ->toBeTrue(); }); + +test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () { + $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); + + expect($layout) + ->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"') + ->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"'); +}); diff --git a/tests/Feature/DefaultThemeTest.php b/tests/Feature/DefaultThemeTest.php new file mode 100644 index 000000000..8aa3543fa --- /dev/null +++ b/tests/Feature/DefaultThemeTest.php @@ -0,0 +1,11 @@ +toContain('toContain("const t = localStorage.theme || 'dark';") + ->toContain("localStorage.theme = 'dark';") + ->toContain(''); +}); diff --git a/tests/Feature/DockerCleanupExecutionStyleTest.php b/tests/Feature/DockerCleanupExecutionStyleTest.php new file mode 100644 index 000000000..bad31d2d1 --- /dev/null +++ b/tests/Feature/DockerCleanupExecutionStyleTest.php @@ -0,0 +1,19 @@ +toContain('bg-white text-neutral-700') + ->toContain('dark:bg-neutral-950 dark:text-neutral-300'); +}); + +test('docker cleanup executions show a loading state while opening an execution', function () { + $view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php')); + + expect($view) + ->toContain('wire:loading.attr="disabled"') + ->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"') + ->toContain('wire:loading.remove') + ->toContain('toContain('@mouseleave="hide()"'); }); +test('clicking a helper does not pin its popup open', function () { + $helper = file_get_contents(resource_path('views/components/helper.blade.php')); + + expect($helper) + ->toContain('@click.prevent.stop="show()"') + ->not->toContain('pinned'); +}); + +test('helper popup stays in its alpine scope during livewire morphs', function () { + $helper = file_get_contents(resource_path('views/components/helper.blade.php')); + + expect($helper) + ->not->toContain('x-teleport'); +}); + test('helper popup supports focus dismissal and tooltip aria relationships', function () { $helper = file_get_contents(resource_path('views/components/helper.blade.php')); expect($helper) ->toContain('closeWhenFocusLeaves()') - ->toContain('@focus="show(false)"') + ->toContain('@focus="show()"') ->toContain('@blur="closeWhenFocusLeaves()"') ->toContain('role="tooltip"') ->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"') diff --git a/tests/Feature/LivewireLoadingColorTest.php b/tests/Feature/LivewireLoadingColorTest.php index bbe42c888..4fe43278e 100644 --- a/tests/Feature/LivewireLoadingColorTest.php +++ b/tests/Feature/LivewireLoadingColorTest.php @@ -1,7 +1,8 @@ toContain('@utility loading-indicator') - ->toContain('@apply text-coollabs dark:text-coollabs-100;') + ->toContain('@apply text-coollabs dark:text-warning;') ->and($loading)->toContain('loading-indicator') ->and($pageLoading)->toContain('loading-indicator') - ->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-warning|dark:text-warning[^"\n]*animate-spin/'); + ->and($appCss)->toContain('.dark .animate-spin') + ->toContain('color: var(--color-warning) !important;') + ->toContain('.dark #nprogress .bar') + ->toContain('background: var(--color-warning) !important;') + ->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/'); }); test('livewire navigation progress bar uses coollabs purple', function () { diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index c06b29743..706860d28 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -90,12 +90,26 @@ beforeEach(function () { ]); }); -it('lists domains grouped by service application on the stack domains page', function () { - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) +it('groups configured domains with their service redirect and excludes services without domains', function () { + $this->apiApp->update([ + 'fqdn' => 'https://api.example.com,https://admin.example.com', + ]); + + $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->assertSuccessful() ->assertSee('API') ->assertSee('https://api.example.com') - ->assertSee('Web'); + ->assertSee('https://admin.example.com') + ->html(); + + expect($html) + ->toContain("service-domain-group-{$this->apiApp->id}") + ->toContain("service-domain-redirect-{$this->apiApp->id}") + ->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"") + ->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"") + ->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}") + ->not->toContain("service-domain-group-{$this->webApp->id}") + ->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1); }); it('shows dns entries control next to Add', function () { @@ -139,14 +153,18 @@ it('lists dns entries for service hosts that still need dns', function () { ->not->toContain('web.example.com'); }); -it('does not persist service redirect until Set Direction is called', function () { +it('persists a service redirect when its dropdown changes', function () { $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) - ->set("serviceRedirects.{$this->webApp->id}", 'www'); + ->set("serviceRedirects.{$this->webApp->id}", 'www') + ->assertDispatched('success') + ->assertSee('https://www.web.example.com'); - expect($this->webApp->fresh()->redirect)->toBe('both') - ->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); + expect($this->webApp->fresh()->redirect)->toBe('www') + ->and(explode(',', (string) $this->webApp->fresh()->fqdn)) + ->toContain('https://web.example.com') + ->toContain('https://www.web.example.com'); }); it('sets redirect direction per service application without changing other apps', function () { @@ -166,6 +184,18 @@ it('sets redirect direction per service application without changing other apps' ->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com'); }); +it('saves the explicitly selected service redirect value', function () { + $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->call('updateServiceRedirect', $this->webApp->id, 'www') + ->assertDispatched('success') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com')) + ->assertSee('https://www.web.example.com'); + + expect($this->webApp->fresh()->redirect)->toBe('www'); +}); + it('auto-adds missing non-www pair for a service application redirect', function () { $this->apiApp->update([ 'fqdn' => 'https://www.api.example.com', @@ -185,7 +215,7 @@ 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 () { +it('adds only the entered domain when redirects allow both directions', function () { $this->webApp->update(['redirect' => 'both']); Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) @@ -196,9 +226,7 @@ it('auto-adds the suggested www pair when adding a service domain with both dire ->assertDispatched('success') ->assertSee('DNS skipped'); - expect(explode(',', (string) $this->webApp->fresh()->fqdn)) - ->toContain('https://web.example.com') - ->toContain('https://www.web.example.com'); + expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); it('adds a domain to a selected service application', function () { @@ -212,24 +240,26 @@ it('adds a domain to a selected service application', function () { ->assertSee('https://web.example.com'); $this->webApp->refresh(); - expect(explode(',', (string) $this->webApp->fqdn)) - ->toBe(['https://web.example.com', 'https://www.web.example.com']); + expect($this->webApp->fqdn)->toBe('https://web.example.com'); $dnsStatuses = $this->webApp->domain_dns_statuses; expect($dnsStatuses) ->toHaveKey('https://web.example.com') - ->toHaveKey('https://www.web.example.com') + ->not->toHaveKey('https://www.web.example.com') ->and($dnsStatuses['https://web.example.com']['status']) ->toBe('skipped') ->and($dnsStatuses['https://web.example.com']['checked_at']) ->not->toBeNull(); }); -it('replaces the rendered domain list when its rows change', function () { +it('keeps a stable key for the rendered domain list', function () { $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php')); - expect($view)->toContain('wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"'); + expect($view) + ->toContain('wire:key="service-domains-list"') + ->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"') + ->not->toContain('md5(serialize($domainRows))'); }); it('does not duplicate the service name as a badge in the domain cell', function () { @@ -279,7 +309,8 @@ it('prunes dns status when a service domain is removed', function () { Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->call('removeDomain', 0) - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->doesntContain('https://api.example.com')); expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull(); }); @@ -334,7 +365,7 @@ it('does not restore stale dns status when a removed service domain is re-added' $this->apiApp->refresh(); expect(explode(',', (string) $this->apiApp->fqdn)) - ->toBe(['https://api.example.com', 'https://www.api.example.com']) + ->toBe(['https://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.'); }); @@ -371,7 +402,7 @@ YAML, ->assertDispatched('success'); expect(explode(',', (string) $this->webApp->fresh()->fqdn)) - ->toBe(['https://api.example.com', 'https://www.api.example.com']); + ->toBe(['https://api.example.com']); }); it('loads persisted dns status for service applications', function () { diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php index 57f1a42d1..09c02a031 100644 --- a/tests/Feature/TerminalPageHeaderTest.php +++ b/tests/Feature/TerminalPageHeaderTest.php @@ -60,6 +60,9 @@ it('defaults to a system console theme that follows the page color mode', functi ->toContain("attributeFilter: ['class', 'data-theme']") ->and($appCss) ->toContain('[data-console-theme="system"]') + ->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]') + ->toContain('--console-theme-border: #d4d4d8') + ->toContain('background: transparent') ->toContain('html.dark .application-console-shell[data-console-theme="system"]'); }); diff --git a/tests/v4/Browser/ServiceConfigurationTest.php b/tests/v4/Browser/ServiceConfigurationTest.php index becad8ef6..8f785ea60 100644 --- a/tests/v4/Browser/ServiceConfigurationTest.php +++ b/tests/v4/Browser/ServiceConfigurationTest.php @@ -94,6 +94,60 @@ it('opens service environment variables domains and storages pages', function () ->screenshot(filename: 'service-storages'); }); +it('renders an automatically added redirect counterpart without reloading', function () { + $this->serviceApplication->update([ + 'fqdn' => 'https://web.example.com', + 'redirect' => 'both', + ]); + + loginAndSkipBoarding(); + + $base = serviceConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->service + ); + + $page = visit("{$base}/domains") + ->assertSee('https://web.example.com'); + + $selector = '#service-domain-redirect-'.$this->serviceApplication->id; + $page->select($selector, 'www') + ->wait(3); + + $this->serviceApplication->refresh(); + expect($this->serviceApplication->redirect)->toBe('www'); + + $page->assertSee('https://www.web.example.com') + ->screenshot(filename: 'service-domain-redirect-counterpart'); + + $page->script(<<<'JS' + (() => { + const domain = document.querySelector('a[title="https://www.web.example.com"]'); + domain.closest('.env-table-item').querySelector('[aria-label="Remove domain"]').click(); + })() + JS); + + $page->wait(0.5) + ->script(<<<'JS' + (() => { + const button = [...document.querySelectorAll('button')] + .find((candidate) => candidate.offsetParent && candidate.textContent.trim() === 'Remove domain'); + button.click(); + })() + JS); + + $page->wait(3); + + $configuredCounterparts = $page->script( + 'document.querySelectorAll(\'a[title="https://www.web.example.com"]\').length' + ); + + expect($configuredCounterparts)->toBe(0); + + $page->screenshot(filename: 'service-domain-remove-without-reload'); +}); + it('opens compose stack application general settings', function () { loginAndSkipBoarding();