Merge remote-tracking branch 'origin/next' into fix/backup-ssh-command-timeout-next

This commit is contained in:
Andras Bacsai committed 2026-08-12 23:40:19 +02:00
commit e2fbd6a16e
70 files changed
+1615 -1412

No files matched your search

+45 -3
View File
@@ -108,12 +108,39 @@ it('lists existing domains as individual rows', function () {
->assertSee('https://example.com')
->assertSee('https://www.example.com')
->assertSee('https://example.com/favicon.ico', false)
->assertSee('class="relative size-4 shrink-0"', false)
->assertSee('domain-favicon-fallback', false)
->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)
->html();
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
});
it('shows one redirect direction control in each compose service header', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([
'api' => [
'domain' => 'https://api.example.com,https://www.api.example.com',
'redirect' => 'www',
],
]),
]);
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSuccessful()
->assertSee('api')
->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);
});
it('shows dns entries control next to Add', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSuccessful()
@@ -1225,15 +1252,25 @@ it('uses the compact service domains layout for compose applications', function
->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"')
->toContain('class="data-table-header domains-table-grid-service"')
->toContain('<span>Direction</span>')
->toContain('<span>Search engine indexing</span>')
->not->toContain('<span>Last checked</span>')
->not->toContain('id="edit-domain-direction"')
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
->toContain('id="domain-direction-service-{{ $redirectWireKey }}"')
->toContain('onChange="updateServiceRedirect"')
->toContain("'showDirectionControl' => false")
->not->toContain('title="No domains for this service"');
});
it('does not render a last checked column in the domains table', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)->not->toContain('<span>Last checked</span>')
->and($row)->not->toContain('$checkedAt');
});
it('uses compact labeled domain cards on mobile', function () {
$styles = file_get_contents(resource_path('css/app.css'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
@@ -1264,6 +1301,10 @@ it('uses segmented fields when adding and editing application domains', function
->toContain("scheme: 'https'")
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
->not->toContain('<select id="{{ $id }}-protocol"')
->toContain("['value' => 'https', 'label' => 'https']")
->toContain("['value' => 'http', 'label' => 'http']")
->toContain('class="mb-1.5 flex h-4 w-full items-center gap-1.5"')
->not->toContain('class="mb-1.5 block text-sm font-medium"')
->toContain('min="1"')
->toContain('max="65535"');
});
@@ -1407,6 +1448,7 @@ it('updates search engine indexing from the domains view', function () {
->assertSee('Direction')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateRedirect', false)
->assertSee('wire:ignore', false)
->assertDontSee('x-model="localIndexing"', false)
->assertDontSee('x-model="localDirection"', false)
->assertDontSee('@js(', false)
@@ -187,6 +187,8 @@ it('keeps deployment history fields and the log status badge accessible on mobil
->and($appCss)
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
->toContain(".deployment-table-grid {\n min-width: 59rem;")
->toContain("@media (min-width: 1024px) {\n .deployment-table-scroll {\n overflow-x: visible;")
->toContain(".deployment-table-grid {\n min-width: 0;")
->not->toContain('.deployment-table-grid > :nth-child')
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
});
@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\Blade;
/**
* Header version badge should open the matching GitHub release page.
*/
@@ -15,3 +17,14 @@ test('desktop header version links to the coolify github release for the install
->toContain("https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}")
->toContain('target="_blank"');
});
test('development versions are not linked to nonexistent github releases', function () {
config(['constants.coolify.version' => '4.3.1-dev.d64cbda3e']);
$version = Blade::render('<x-version />');
expect($version)
->toContain('v4.3.1-dev.d64cbda3e')
->not->toContain('href=')
->not->toContain('target="_blank"');
});
@@ -88,6 +88,31 @@ test('listbox forwards dynamic disabled state to its trigger', function () {
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
});
test('listbox waits for change handlers and prevents overlapping selections', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
expect($listbox)
->toContain('saving: false')
->toContain('async choose(option)')
->toContain('await this.$wire.')
->toContain('if (this.saving || option.disabled) return;')
->toContain("'pointer-events-none opacity-70': saving");
});
test('listbox does not send a second live entangle request when using a change handler', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
});
test('listbox can preserve its client value across Livewire morphs', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
expect($listbox)
->toContain("'preserveValue' => false")
->toContain('@if ($preserveValue) wire:ignore @endif');
});
test('notification event multiselect truncates long selected summaries', function () {
$html = Blade::render(<<<'BLADE'
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
@@ -75,6 +75,7 @@ it('renders the changed configuration labels without a second backend request',
expect($view)
->toContain(':compact-after="5000"')
->toContain('position="top-right"')
->toContain(':compact-storage-key="$compactStorageKey"')
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
->toContain('x-on:click="configurationDiffModalOpen = true"')
@@ -85,6 +86,7 @@ it('supports timed compact popup notifications', function () {
$view = file_get_contents(resource_path('views/components/popup-small.blade.php'));
expect($view)
->toContain("\$position === 'top-right' ? 'top-16' : 'bottom-4'")
->toContain('compactAfter')
->toContain('compactStorageKey')
->toContain("localStorage.setItem(this.storageKey, 'compact')")
@@ -94,8 +96,10 @@ it('supports timed compact popup notifications', function () {
->toContain('compact = true')
->toContain('@click="restore()"')
->toContain('@click.stop="minimizeToIcon()"')
->toContain('x-show="iconOnly"')
->toContain('x-show="!iconOnly"')
->toContain('<template x-if="iconOnly">')
->toContain('<template x-if="!iconOnly">')
->not->toContain('<button x-show="iconOnly"')
->not->toContain('<div x-show="!iconOnly"')
->not->toContain(':class="iconOnly')
->toContain('x-show="!compact"')
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
+9
View File
@@ -0,0 +1,9 @@
<?php
test('confirmation modal closes before dispatching an event that can open another modal', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
expect($modal)->toMatch(
'/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s'
);
});
@@ -12,6 +12,7 @@ it('aggregates preview container and health check status', function () {
->toContain('Health check')
->toContain('Not configured')
->toContain('aria-label="About unconfigured health checks"')
->toContain('class="relative inline-flex align-middle"')
->toContain('Traffic can still be routed to the container')
->toContain('aria-haspopup="menu"')
->toContain('right-auto! left-0!')
@@ -27,6 +27,13 @@ it('opens the email change form without a Livewire request', function () {
->not->toContain('wire:click="showEmailChangeForm"');
});
it('does not show a redundant enabled badge for two-factor authentication', function () {
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
expect($profileView)
->not->toContain('<x-status-badge status="Enabled" type="success" />');
});
it('keeps color theme preferences on the profile appearance view without page width or density controls', function () {
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
+9 -2
View File
@@ -104,14 +104,21 @@ it('falls back cleanly when the avatars S3 storage no longer exists', function (
->assertNotFound();
});
it('renders the profile upload and user menu avatar', function () {
it('automatically uploads a selected profile picture and keeps the current avatar until it succeeds', function () {
$profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($profile)
->toContain("this.\$wire.upload('avatar', compressed")
->toContain('await this.$wire.uploadAvatar()')
->toContain('if (uploaded)')
->toContain('canvas.toBlob')
->toContain('wire:click="uploadAvatar"')
->toContain('x-ref="avatarInput"')
->toContain('class="hidden"')
->toContain("processing ? 'Uploading…' : 'Browse…'")
->not->toContain('wire:click="uploadAvatar"')
->not->toContain('Upload picture')
->not->toContain('type="file" x-on:change')
->and($menu)
->toContain("route('profile.avatar',");
});
+81 -32
View File
@@ -186,17 +186,11 @@ it('uses the redesigned terminal canvas and controls on resource terminal pages'
->not->toContain('application-console-header flex h-[30px]');
});
it('uses a readable theme-aware terminal session expiry label', function () {
it('does not overlay the session expiry label on the application terminal', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('terminal-session-expiry')
->and($styles)
->toContain('.terminal-session-expiry')
->toContain('font-size: 0.75rem;')
->toContain('color: rgb(255 255 255 / 0.6);')
->toContain('.terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry');
->not->toContain('terminal-session-expiry');
});
it('copies the realtime terminal utilities into the container image', function () {
@@ -329,27 +323,52 @@ it('preserves terminal scrollback across transient reconnects', function () {
->not->toContain("this.term.reset();\n this.term.clear();");
});
it('renders a compact mobile terminal toolbar with shell control keys', function () {
it('renders a horizontally scrollable mobile terminal key row', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('Terminal keys')
->toContain('sm:hidden')
->toContain("sendTerminalControl('arrowUp')")
->toContain("sendTerminalControl('arrowDown')")
->toContain("sendTerminalControl('arrowLeft')")
->toContain("sendTerminalControl('arrowRight')")
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
->toContain('overflow-x-auto')
->toContain('whitespace-nowrap')
->toContain('pasteFromClipboard()')
->toContain('copyTerminalSelection()')
->toContain("sendTerminalControl('tab')")
->toContain("sendTerminalControl('escape')")
->not->toContain("sendTerminalControl('ctrlC')")
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'")
->toContain('sendTerminalControl(\'escape\')">ESC</button>')
->toContain("toggleTerminalModifier('ctrl')")
->toContain("toggleTerminalModifier('alt')")
->toContain("sendTerminalKey('/')")
->toContain("sendTerminalKey('|')")
->toContain("sendTerminalKey('~')")
->toContain("sendTerminalKey('-')")
->toContain("sendTerminalControl('ctrlC')")
->toContain("sendTerminalControl('ctrlBackslash')")
->toContain("sendTerminalControl('ctrlS')")
->toContain("sendTerminalControl('ctrlZ')")
->not->toContain("sendTerminalControl('arrowUp')")
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
->toContain('.terminal-mobile-key')
->toContain('min-h-8')
->toContain('rounded-full')
->toContain('.terminal-key-row')
->toContain('background: transparent;')
->toContain('var(--terminal-scrollbar');
});
it('shows the terminal key row outside fullscreen mode', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalView)
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
->and($terminalClient)
->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
->not->toContain("this.\$refs.terminalWrapper.style.display = 'block'");
});
it('sends terminal mobile toolbar controls through the websocket', function () {
@@ -365,8 +384,15 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
->toContain("ctrlBackslash: '\\x1c'")
->toContain("ctrlS: '\\x13'")
->toContain("ctrlZ: '\\x1a'")
->toContain('toggleTerminalModifier(modifier)')
->toContain('sendTerminalKey(key)')
->toContain('navigator.clipboard.readText()')
->toContain('navigator.clipboard.writeText(selection)');
->toContain('navigator.clipboard.writeText(selection)')
->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.sendMessage({ message: data });")
->not->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.term.focus();");
});
it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () {
@@ -379,24 +405,40 @@ it('uses terminal host dimensions when resizing so mobile controls do not cover
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
it('keeps the fullscreen mobile toolbar above the software keyboard', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->not->toContain('updateFullscreenLayout()')
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->toContain('keyboardInset: 0')
->toContain('keyboardAnchorTop: 0')
->toContain('keyboardViewportHeight: 0')
->toContain('updateKeyboardInset()')
->toContain('window.visualViewport')
->toContain('viewport.height + viewport.offsetTop')
->toContain('this.keyboardViewportHeight - visualBottom')
->toContain('this.keyboardAnchorTop = Math.round(visualBottom)')
->toContain('syncFullscreenShellWithKeyboard(viewport)')
->toContain("wrapper.style.setProperty('bottom', 'auto', 'important')")
->toContain("window.visualViewport?.addEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset)")
->toContain("window.addEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset)")
->toContain("window.removeEventListener('resize', this.syncKeyboardInset)")
->and($terminalView)
->toContain("mobileToolbarCollapsed\n ? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'\n : 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2'");
->toContain("'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'")
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2'")
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
it('resizes after the mobile keyboard viewport changes', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalView)
->toContain('$nextTick(() => resizeTerminal())');
expect($terminalClient)
->toContain('window.visualViewport')
->toContain('this.$nextTick(() => this.resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
@@ -438,6 +480,13 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
});
it('keeps the application terminal fullscreen control visible on mobile', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100');
});
it('lets the selected theme show through the active terminal panel', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
@@ -113,17 +113,27 @@ it('places the account menu beside the desktop sidebar toggle while retaining it
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
});
it('keeps application links next to advanced actions on the right', function () {
it('keeps advanced operations in a separated section at the bottom of actions menus', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$desktop = str($application)->after('resource-heading-actions flex')->toString();
$advancedPosition = strpos($desktop, '<x-applications.advanced');
$linksPosition = strpos($desktop, '<x-applications.links');
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
expect($advancedPosition)->not->toBeFalse()
->and($linksPosition)->not->toBeFalse()
->and($linksPosition)->toBeGreaterThan($advancedPosition)
expect($applicationDesktop)
->not->toContain('<x-applications.advanced')
->toContain('application-desktop-actions')
->toContain('role="separator"')
->toContain('Force deploy without cache')
->and($serviceDesktop)
->not->toContain('<x-services.advanced')
->toContain('service-desktop-actions')
->toContain('role="separator"')
->toContain('Pull Latest Images & Restart')
->toContain('Force Restart')
->toContain('Force Deploy')
->toContain('Force Cleanup Containers')
->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth")
->and($links)->toContain('listbox-option justify-start! gap-2.5!')
->and($links)->not->toContain('md:left-0 md:right-auto');
@@ -146,15 +156,27 @@ it('groups application lifecycle controls in an actions dropdown', function () {
->toContain('Deploy');
});
it('shows deploy directly when it is the only available lifecycle action', function () {
it('raises the desktop top bar while the service actions dropdown is open', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
expect($layout)
->toContain('resourceActionsOpen: false')
->toContain("'z-[1000]': resourceActionsOpen")
->toContain('@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"')
->and($heading)
->toContain("\$dispatch('resource-actions-toggled', { open })");
});
it('keeps deploy in the actions menu alongside advanced operations', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain("@if (str(\$application->status)->startsWith('exited'))")
->toContain('id="application-desktop-deploy"')
->toContain('@else')
->toContain('id="application-desktop-actions"');
->toContain('id="application-desktop-actions"')
->toContain('Deploy')
->toContain('Force deploy without cache');
});
it('moves application backups from the top tabs into the settings sidebar', function () {
@@ -229,7 +251,24 @@ it('uses neutral icons for non-destructive resource actions', function () {
->not->toContain('class="size-3.5 text-orange-500')
->not->toContain('class="size-3.5 text-warning"')
->not->toContain('class="size-4 text-warning"')
->toContain('name="stop" class="size-3.5 text-error"');
->toContain('name="stop-circle"');
}
});
it('uses a circular stop icon in application and service action menus', function () {
$icons = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($icons)
->toContain("'stop-circle' =>")
->toContain('<circle cx="12" cy="12"')
->toContain('<rect x="8.25" y="8.25"');
foreach (['application', 'service'] as $resource) {
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
expect($heading)
->toContain('name="stop-circle" class="size-3.5 text-error"')
->not->toContain('name="stop" class="size-3.5 text-error"');
}
});
@@ -52,3 +52,15 @@ test('searchable listbox keeps the helper outside the label association', functi
->toContain('aria-label="More information"')
->not->toMatch('/<label[^>]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/');
});
test('searchable listbox serializes change handlers', function () {
$listbox = file_get_contents(resource_path('views/components/forms/searchable-listbox.blade.php'));
expect($listbox)
->toContain('saving: false')
->toContain('async choose(option)')
->toContain('if (this.saving || option.disabled)')
->toContain('await this.$wire.')
->toContain("'pointer-events-none opacity-70': saving")
->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Models\Server;
use App\Models\User;
use Database\Seeders\SentinelSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('uses the configured development Sentinel URL for seeded servers', function () {
DB::table('instance_settings')->insert(['id' => 0]);
$user = User::factory()->create();
$server = Server::factory()->create([
'team_id' => $user->teams()->first()->id,
]);
DB::table('server_settings')->where('id', $server->settings->id)->update([
'sentinel_custom_url' => 'http://host.docker.internal:8000',
]);
config()->set('app.env', 'local');
config()->set('constants.sentinel.dev_url', 'https://coolify-dev.example.com:8000');
app(SentinelSeeder::class)->run();
expect($server->settings->fresh()->sentinel_custom_url)
->toBe('https://coolify-dev.example.com:8000');
});
+26 -1
View File
@@ -31,11 +31,36 @@ test('unsaved bar delays show and hides while loading to avoid instant-save flas
expect($contents)
->toContain('wire:dirty.class="is-dirty"')
->toContain('wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"')
->toContain('wire:loading.class="is-saving"')
->toContain('[&.is-dirty]:delay-300')
->toContain('delay-0');
});
test('unsaved bar uses a stable transition for its entrance', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
expect($contents)
->toContain('scale-95')
->toContain('transition-[opacity,transform,scale]')
->toContain('duration-300')
->toContain('ease-[cubic-bezier(0.16,1,0.3,1)]')
->toContain('[&.is-dirty]:scale-100')
->not->toContain('[&.is-dirty]:animate-in');
});
test('unsaved bar transitions out without restarting its entrance animation', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
expect($contents)
->toContain('[&.is-saving]:translate-y-6')
->toContain('[&.is-saving]:scale-95')
->toContain('[&.is-saving]:opacity-0')
->toContain('[&.is-saving]:duration-200')
->toContain('[&.is-saving]:ease-in')
->toContain('[&.is-saving]:pointer-events-none')
->not->toContain('[&.is-saving]:animate-out');
});
test('unsaved bar saves with enter and shows the shortcut on the save button', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
@@ -41,3 +41,20 @@ it('uses the branded input focus state for the server filter', function () {
->toContain('<x-reicon name="check-circle"')
->not->toContain('<x-reicon name="check"');
});
it('groups all desktop proxy controls in the server actions dropdown', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$desktopActions = str($navbar)->after('id="server-desktop-actions"')->before('@endteleport')->toString();
expect($desktopActions)
->toContain('Actions')
->toContain('Traefik Dashboard')
->toContain('name="external-link" class="size-3! opacity-70"')
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
->toContain('Restart Proxy')
->toContain('Stop Proxy')
->toContain('Start Proxy')
->toContain('Refresh Proxy Status')
->toContain('listbox-panel')
->not->toContain('<x-modal-confirmation');
});
@@ -14,9 +14,12 @@ test('server cards use icon borders instead of ready badges', function () {
expect(substr_count($serverIndex, '<x-status-badge'))->toBe(1)
->and($serverIndex)
->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'")
->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()')
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'")
->toContain("\$isReady => 'success'")
->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'")
->toContain("default => 'warning'")
->toContain("default => 'error'")
->toContain("server.statusType === 'success' ? 'border-emerald-500/70'")
->toContain("server.statusType === 'warning' ? 'border-amber-500/70'")
->toContain("'border-red-500/70'")
@@ -24,6 +27,15 @@ test('server cards use icon borders instead of ready badges', function () {
->toContain(':aria-label="`Server status: ${server.status}`"');
});
test('dashboard server cards warn when proxy or sentinel needs attention', function () {
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
expect($dashboard)
->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'")
->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()')
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']");
});
test('server table keeps status text without a badge', function () {
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
+12 -3
View File
@@ -107,13 +107,18 @@ it('groups configured domains and shows redirect settings in the table', functio
->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="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('<span>Domain</span>')
->toContain('<span>DNS</span>')
->toContain('<span>Last checked</span>')
->toContain('<span>DNS Check</span>')
->not->toContain('<span>Last checked</span>')
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
@@ -556,12 +561,16 @@ it('updates search engine indexing from the service domains view', function () {
->assertSee('Direction')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateServiceRedirect', false)
->assertSee('wire:ignore', false)
->assertDontSee('x-model="localIndexing"', false)
->assertDontSee('x-model="localDirection"', false)
->assertDontSee('@js(', false)
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
->assertDispatched('configurationChanged')
->assertDispatched('success');
->assertDispatched('success')
->assertSet('service', fn (Service $service): bool => $service->applications
->firstWhere('id', $this->apiApp->id)
?->isDomainNoindexed('https://api.example.com') === true);
expect($this->apiApp->refresh()->noindexDomains()->all())
->toBe(['https://api.example.com']);
@@ -0,0 +1,144 @@
<?php
use App\Livewire\Team\Member;
use App\Livewire\Team\Member\Index;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
]));
$this->team = Team::factory()->create();
});
function createTeamMember(Team $team, string $role, bool $twoFactorEnabled = false): User
{
$user = User::factory()->create([
'name' => fake()->unique()->userName(),
'two_factor_confirmed_at' => $twoFactorEnabled ? now() : null,
]);
$team->members()->attach($user->id, ['role' => $role]);
return $user;
}
function actAsTeamMember(User $user, Team $team): void
{
test()->actingAs($user);
session(['currentTeam' => $team]);
}
test('admins see the two factor column with the status of every member', function () {
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
createTeamMember($this->team, 'member');
actAsTeamMember($admin, $this->team);
Livewire::test(Index::class)
->assertSee('2FA')
->assertSee('Two-factor authentication is enabled')
->assertSee('Two-factor authentication is disabled');
});
test('owners see the two factor column and summary', function () {
$owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true);
createTeamMember($this->team, 'member');
actAsTeamMember($owner, $this->team);
Livewire::test(Index::class)
->assertSee('2FA')
->assertSee('1 of 2 members does not have two-factor authentication enabled');
});
test('members without member management rights do not see the two factor column', function () {
createTeamMember($this->team, 'owner', twoFactorEnabled: true);
$member = createTeamMember($this->team, 'member');
actAsTeamMember($member, $this->team);
Livewire::test(Index::class)
->assertDontSee('2FA')
->assertDontSee('Two-factor authentication is enabled')
->assertDontSee('Two-factor authentication is disabled')
->assertDontSee('two-factor authentication enabled');
});
test('the member row renders an enabled badge when two factor is confirmed', function () {
$admin = createTeamMember($this->team, 'admin');
$memberWithTwoFactor = createTeamMember($this->team, 'member', twoFactorEnabled: true);
actAsTeamMember($admin, $this->team);
Livewire::test(Member::class, ['member' => $memberWithTwoFactor])
->assertSee('Two-factor authentication is enabled')
->assertDontSee('Two-factor authentication is disabled');
});
test('the member row renders a disabled badge when two factor is not configured', function () {
$admin = createTeamMember($this->team, 'admin');
$memberWithoutTwoFactor = createTeamMember($this->team, 'member');
actAsTeamMember($admin, $this->team);
Livewire::test(Member::class, ['member' => $memberWithoutTwoFactor])
->assertSee('Two-factor authentication is disabled')
->assertDontSee('Two-factor authentication is enabled');
});
test('admins see the two factor status of every role, including owners and other admins', function (string $role, bool $twoFactorEnabled, string $expectedStatus) {
$admin = createTeamMember($this->team, 'admin');
$otherMember = createTeamMember($this->team, $role, twoFactorEnabled: $twoFactorEnabled);
actAsTeamMember($admin, $this->team);
Livewire::test(Member::class, ['member' => $otherMember])
->assertSee($expectedStatus);
})->with([
'owner with two factor' => ['owner', true, 'Two-factor authentication is enabled'],
'owner without two factor' => ['owner', false, 'Two-factor authentication is disabled'],
'another admin with two factor' => ['admin', true, 'Two-factor authentication is enabled'],
'another admin without two factor' => ['admin', false, 'Two-factor authentication is disabled'],
'member with two factor' => ['member', true, 'Two-factor authentication is enabled'],
'member without two factor' => ['member', false, 'Two-factor authentication is disabled'],
]);
test('admins see the two factor status on their own row', function () {
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
actAsTeamMember($admin, $this->team);
Livewire::test(Member::class, ['member' => $admin])
->assertSee('You')
->assertSee('Two-factor authentication is enabled');
});
test('the summary counts the members that are missing two factor authentication', function () {
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
createTeamMember($this->team, 'member');
actAsTeamMember($admin, $this->team);
Livewire::test(Index::class)
->assertSee('1 of 2')
->assertSee('does not have two-factor authentication enabled');
});
test('the summary confirms when every member has two factor authentication', function () {
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
createTeamMember($this->team, 'member', twoFactorEnabled: true);
actAsTeamMember($admin, $this->team);
Livewire::test(Index::class)
->assertSee('All members have two-factor authentication enabled')
->assertDontSee('do not have two-factor authentication enabled');
});
+10
View File
@@ -80,6 +80,16 @@ it('opens the global terminal outside Livewire navigation like resource terminal
->not->toMatch('/<a title="Terminal"[^>]*wireNavigate\(\)/s');
});
it('keeps the server terminal navigation active during Livewire requests', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
expect($sidebar)
->toContain("'active' => \$activeMenu === 'terminal'")
->and($navbar)
->toContain("'active' => \$currentRoute === 'server.command'");
});
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
+26
View File
@@ -0,0 +1,26 @@
<?php
test('toast messages default to the bottom right while supporting position overrides', function () {
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
expect($toast)
->toContain("position: options.position ?? 'bottom-right'")
->toContain("position: 'bottom-right'")
->toContain("this.position = event.detail.position || 'bottom-right'")
->toContain("'right-4 bottom-4 flex-col-reverse': position === 'bottom-right'")
->toContain("'left-1/2 top-4 -translate-x-1/2 flex-col': position === 'top-center'");
});
test('toast copy button shows temporary success feedback', function () {
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
expect($toast)
->toContain('copied: false')
->toContain('copyToast(toast)')
->toContain('toast.copied = true')
->toContain('toast.copied = false')
->toContain('}, 2000)')
->toContain('x-show="!toast.copied"')
->toContain('x-show="toast.copied"')
->toContain("'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied");
});
+14
View File
@@ -0,0 +1,14 @@
<?php
test('shared tooltips render above toasts and notification banners', function () {
$iconTooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($iconTooltip)->toContain('z-[10000]')
->and($helper)->toContain('z-[10000]')
->and($navbar)->toContain('z-[10000]')
->and($utilities)->toContain('@utility auth-tooltip')
->and($utilities)->toContain('@apply fixed z-[10000]');
});
+83 -30
View File
@@ -1,55 +1,108 @@
<?php
it('publishes v4 branch builds only under the commit sha', function () {
it('publishes v4 branch builds under the commit sha with a traceable internal version', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-sha-build.yml');
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile');
$constants = file_get_contents(dirname(__DIR__, 2).'/config/constants.php');
expect($workflow)
->toContain('name: Build Coolify (SHA)')
->toContain('branches: ["v4.x"]')
->not->toContain('branches: ["v4.x", "main"]')
->toContain('sha-${{ github.sha }}-${{ matrix.arch }}')
->toContain('sha-${{ github.sha }}')
->not->toContain('bootstrap/getVersion.php')
->not->toContain('steps.version.outputs.VERSION')
->not->toContain('IMAGE_NAME }}:latest');
->toContain('php bootstrap/getVersion.php')
->toContain('version=${BASE_VERSION}-dev.${GITHUB_SHA::9}')
->toContain('COOLIFY_VERSION=${{ steps.version.outputs.version }}')
->not->toContain('IMAGE_NAME }}:latest')
->and($dockerfile)
->toContain('ARG COOLIFY_VERSION')
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
->and($constants)
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.0'");
});
it('promotes the released commit image without rebuilding it', function () {
it('orders a maintenance development build before its stable release', function () {
expect(version_compare('4.3.0-dev.d64cbda3e', '4.3.0', '<'))->toBeTrue()
->and(version_compare('4.3.0', '4.3.0-dev.d64cbda3e', '>'))->toBeTrue();
});
it('requires a reviewed draft release before building a stable version', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
expect($workflow)
->toContain('release:')
->toContain('types: [published]')
->toContain('TAG_NAME: ${{ github.event.release.tag_name }}')
->toContain('git rev-list -n 1 "${TAG_NAME}"')
->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"')
->toContain('name: Release Coolify Stable')
->toContain('workflow_dispatch:')
->toContain('tag:')
->toContain('github.ref_name != \'v4.x\'')
->toContain('github.paginate(github.rest.repos.listReleases')
->toContain('release.draft')
->toContain('release.prerelease')
->toContain('release.body?.trim()')
->toContain('bootstrap/getVersion.php')
->toContain('--tag "${IMAGE}:${VERSION}"')
->not->toContain('docker/build-push-action');
->toContain('target_commitish: context.sha')
->toContain('tag_name: process.env.TAG_NAME')
->toContain('actions/github-script@v8')
->not->toContain('actions/github-script@v7')
->not->toContain('generate-notes');
});
it('only promotes stable releases to latest', function () {
it('keeps support image workflows ready for the production branch rename', function (string $workflowFile) {
$workflow = file_get_contents(dirname(__DIR__, 2)."/.github/workflows/{$workflowFile}");
expect($workflow)->toContain('branches: [ "v4.x", "main" ]');
})->with([
'helper' => 'coolify-helper.yml',
'realtime' => 'coolify-realtime.yml',
]);
it('generates the production changelog only from v4.x', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml');
expect($workflow)
->toContain('branches: [ v4.x ]')
->not->toContain('main');
});
it('excludes only active production branches from staging builds', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml');
expect($workflow)
->toContain(' - v4.x')
->not->toContain(' - main');
});
it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
expect($workflow)
->toContain('if: ${{ ! github.event.release.prerelease }}')
->toContain('--tag "${IMAGE}:latest"');
->toContain('docker/build-push-action@v6')
->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
->toContain('release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}')
->toContain('--tag "${IMAGE}:${VERSION}"')
->toContain('--tag "${IMAGE}:latest"')
->toContain('github.rest.repos.getRelease')
->toContain('release.target_commitish !== context.sha')
->toContain('release_id: Number(process.env.RELEASE_ID)')
->toContain('draft: false')
->toContain('permissions: {}')
->not->toContain('sarisia/actions-status-discord@v1')
->not->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"');
});
it('documents the sha image release process', function () {
it('documents the production, rc, and hotfix release flows', function () {
$releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
expect($releaseGuide)
->toContain('## Branch Strategy')
->toContain('Fixes and release-ready patches')
->toContain('open PRs against **`v4.x`**')
->toContain('open PRs against **`next`**')
->toContain('merge `v4.x` back into `next`')
->toContain('Merge the release commit into `v4.x`')
->toContain('`Build Coolify (SHA)`')
->toContain('`sha-<commit-sha>`')
->toContain('targeting the exact commit that produced the SHA image')
->toContain('promotes the existing SHA image without rebuilding it')
->toContain('Update the CDN')
->toContain('Only commits on **`v4.x`** produce production SHA images')
->not->toContain('Merging to `main`')
->not->toContain('Production Build (v4)');
->toContain('| `main` | Latest production source |')
->toContain('| `next` | Feature integration and RC releases |')
->toContain('| `hotfix/X.Y.Z` | Production fixes based on `main` |')
->toContain('feature/* → next → RC')
->toContain('next → main → stable release')
->toContain('main → hotfix/X.Y.Z → main → next')
->toContain('reviewed draft GitHub Release')
->toContain('workflows never edit or commit versions')
->toContain('Update the CDN only after the release is approved')
->not->toContain('`edge`')
->not->toContain('promotes the existing SHA image');
});