mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-12 22:14:26 +00:00
Merge remote-tracking branch 'origin/next' into fix/backup-ssh-command-timeout-next
This commit is contained in:
commit
e2fbd6a16e
70 files changed
+1615
-1412
No files matched your search
@@ -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)]'");
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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',");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user