Merge remote-tracking branch 'origin/next' into celld-one-click-service

This commit is contained in:
Andras Bacsai committed 2026-08-07 18:01:47 +02:00
commit da43086a32
100 files changed
+1349 -493

No files matched your search

+2 -1
View File
@@ -40,7 +40,7 @@ test('custom color theme is available and applied across theme controls', functi
->toContain('html[data-theme="custom"] .control-selected')
->toContain('--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));')
->toContain('--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b);')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 82%, var(--theme-base-color));')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color));')
->toContain('html[data-theme="custom"] *')
->toContain('scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);')
->toContain('html[data-theme="custom"] *::-webkit-scrollbar-thumb')
@@ -72,6 +72,7 @@ test('custom color theme is available and applied across theme controls', functi
->toContain('border-color: var(--coollabs-hairline) !important;')
->toContain('html[data-theme="custom"] input::placeholder')
->toContain('color: var(--theme-placeholder-color) !important;')
->toMatch('/html\[data-theme="custom"\] input::placeholder,[^{]+\{[^}]*opacity: 0\.7;/s')
->toContain('html[data-theme="custom"] input:read-only')
->and($deploymentLogs)
->toContain('dark:bg-log')
@@ -8,3 +8,12 @@ it('uses the shared status summary in the primary application server card', func
->toContain('<x-status-summary :status="$resource->status" />')
->not->toContain('<x-status :resource="$resource"');
});
it('links each configured server card to its server page', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
expect($view)
->toContain("route('server.show', ['server_uuid' => data_get(\$resource, 'destination.server.uuid')])")
->toContain("route('server.show', ['server_uuid' => data_get(\$destination, 'server.uuid')])")
->toContain('Open server');
});
+15 -1
View File
@@ -16,5 +16,19 @@ test('compose actions are grouped with the application details header', function
test('docker compose heading separates its title and action', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($view)->toContain('<div class="flex items-center gap-4">');
expect($view)
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
->toContain('<div class="flex items-center justify-between gap-4">');
});
test('onboarding uses the reusable advanced settings component', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$onboarding = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('id="dockerComposeCustomBuildCommand"')
->not->toContain('<x-forms.collapsible class="pt-4" content-class="grid gap-4">')
->not->toContain('The following commands are for advanced use cases.')
->and($onboarding)
->toContain('<x-forms.collapsible title="Advanced Connection Settings"');
});
@@ -0,0 +1,74 @@
<?php
test('boarding utility actions are centered', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"');
});
test('server validation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl">')
->toContain('@click="processDialogOpen = true"')
->not->toContain('<x-slide-over closeWithX fullScreen>');
});
test('DigitalOcean is available as an onboarding server provider', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>')
->toContain('<x-digital-ocean-icon class="size-10 shrink-0" />')
->toContain('Deploy servers directly from your DigitalOcean account.')
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
});
test('server type details are shown on the relevant cards instead of a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('aria-label="About this machine"')
->toContain('aria-label="About remote servers"')
->toContain('Not recommended for production workloads due to resource contention.')
->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.')
->not->toContain('<x-highlighted text="Servers" />')
->not->toContain('<x-highlighted text="Localhost:" />')
->not->toContain('<x-highlighted text="Remote Server:" />');
});
test('server type cards use the standard card hover treatment', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
$hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md';
preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches);
expect($matches[0])->toHaveCount(5)
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(5)
->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]')
->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]');
});
test('existing SSH key selection does not show a redundant value tooltip', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"');
});
test('server connection step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Connection Requirements:" />')
->not->toContain('<x-highlighted text="Hostname Resolution:" />')
->not->toContain('<x-highlighted text="User Permissions:" />');
});
test('project step does not render a technical details panel', function () {
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->not->toContain('<x-highlighted text="Project Organization:" />')
->not->toContain('<x-highlighted text="Environments:" />')
->not->toContain('<x-highlighted text="Team Access:" />');
});
+64
View File
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the large modal treatment for the compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
->not->toContain("saving ? 'Saving...' : 'Save changes'")
->not->toContain(' :disabled="saving"')
->toContain('Preview generated Compose')
->toContain('Back to source Compose')
->toContain('Save changes')
->toContain('isHighlighted');
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
});
it('renders the compose editor with clear guidance settings and actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
expect($view)
->toContain('<x-callout type="info" title="Volume names">')
->not->toContain('View the final names')
->toContain('Use plain-text editor')
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
->not->toContain('Show Normal Textarea')
->not->toContain('Show Deployable Compose');
});
it('keeps the save button loading until the parent compose save finishes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
expect($component)
->toContain('public function saveCompose($raw)')
->toContain("\$this->dispatch('compose-save-finished')");
});
it('does not show a saving notification for compose changes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/EditCompose.php'));
expect($component)->not->toContain("\$this->dispatch('info', 'Saving new docker compose...')");
});
it('keeps the saving state as an Alpine button binding', function () {
$html = Blade::render('<x-forms.button x-bind:disabled="saving">Save changes</x-forms.button>');
expect($html)->toContain('x-bind:disabled="saving"');
});
@@ -5,6 +5,8 @@ it('uses a database list editor instead of a comma-separated text field', functi
expect($view)
->toContain('<div class="grid w-full gap-4">')
->toContain("['value' => true, 'label' => 'All databases']")
->toContain("['value' => false, 'label' => 'Specific databases']")
->toContain("value: @entangle('databasesToBackup').live")
->toContain('class="chip-input"')
->toContain('class="chip font-mono"')
+6 -1
View File
@@ -15,7 +15,12 @@ it('matches the storage backup overview layout', function () {
->toContain('placeholder="Search backups"')
->toContain('data-table overflow-hidden rounded-xl border')
->toContain('<span class="block truncate text-[12px] font-semibold')
->toContain('<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>')
->toContain("route('project.database.backup.execution'")
->toContain("route('project.service.database.backup.show'")
->toContain("route('project.database.backup.executions'")
->toContain("route('project.service.database.backup.executions'")
->toContain('<a wire:navigate href="{{ $backupExecutionsRoute }}"')
->toContain('<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>')
->and($component)->toContain("withCount('executions')");
});
@@ -0,0 +1,28 @@
<?php
it('shows a loading indicator while database public access is changing', function () {
$databaseTypes = [
'clickhouse',
'dragonfly',
'keydb',
'mariadb',
'mongodb',
'mysql',
'postgresql',
'redis',
];
foreach ($databaseTypes as $databaseType) {
$generalSettings = file_get_contents(resource_path("views/livewire/project/database/{$databaseType}/general.blade.php"));
expect($generalSettings)
->toContain('<x-table.loading target="instantSave" text="Updating public access..." />')
->toContain("'label' => blank(\$publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy'")
->toContain("'disabled' => blank(\$publicPort)")
->toContain('wire:key="public-access-{{ $publicPort ?: \'unset\' }}"')
->not->toContain('x-data="{ publicPort:')
->not->toContain('x-effect="options[1].disabled')
->not->toContain('id="publicPort" x-on:input=')
->not->toContain('id="publicPort" live');
}
});
@@ -0,0 +1,10 @@
<?php
test('database restore output opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
expect($view)
->toContain('<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">')
->toContain('<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />')
->not->toContain('<x-slide-over @databaserestore.window="slideOverOpen = true"');
});
@@ -0,0 +1,23 @@
<?php
it('keeps spacing around the Docker Compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)
->toContain('class="application-settings-section-body"')
->not->toContain('class="application-settings-section-body p-0!"');
});
it('lets the Docker Compose editor use the available width', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)
->toContain('class="mt-8 w-full lg:mt-3"')
->not->toContain('max-w-[1180px]');
});
it('shows a loading indicator while creating the service', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
expect($view)->toContain('<x-forms.button type="submit" wire:target="submit" isHighlighted>');
});
@@ -0,0 +1,9 @@
<?php
it('shows that image tag and digest are mutually exclusive', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/docker-image.blade.php'));
expect($view)
->toContain('aria-label="Tag and SHA256 digest are mutually exclusive"')
->toContain('>OR</span>');
});
+32
View File
@@ -42,6 +42,38 @@ it('insets empty states nested inside a flush settings section wrapper', functio
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('insets the backup executions empty state from the table header', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)->toMatch('/@empty\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/');
});
it('keeps backup execution actions compact', function () {
$css = file_get_contents(resource_path('css/app.css'));
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('uses the compact resource table styling for backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('class="data-table deployment-table-scroll"')
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
->toContain('flex min-h-11 items-center justify-between border-t')
->and($css)
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
});
it('renders compact size without the full-page min height class', function () {
$html = $this->blade(
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
@@ -105,6 +105,32 @@ describe('GitHub Private Repository Component', function () {
->assertSet('selected_repository_id', 1);
});
test('repository selection uses the shared listbox and disables loading action', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('id="selected_repository_id-trigger"', false)
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="loadBranches,selected_repository_id"', false)
->assertDontSee('<datalist', false);
});
test('continue button uses the shared submit loading indicator', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->set('branches', collect([['name' => 'main']]))
->assertSee('type="submit"', false)
->assertSee('wire:target="submit"', false)
->assertSee('wire:loading.class="is-loading"', false);
});
test('loadRepositories rejects a github app owned by another team', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
@@ -46,6 +46,12 @@ beforeEach(function () {
});
describe('GitLab App authorization', function () {
test('empty gitlab app state is inset from the section edges', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/gitlab-private-repository.blade.php'));
expect($view)->toContain("<div class=\"application-settings-section-body\">\n <x-empty title=\"No GitLab Apps\"");
});
test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
$otherTeam = Team::factory()->create();
$systemWideSource = GitlabApp::create([
@@ -25,3 +25,10 @@ it('uses a single Alpine result renderer for every command palette result type',
->toContain('x-for="(result, index) in searchResults"')
->toContain('x-for="[categoryName, items] in Object.entries(groupedCreatableItems)"');
});
it('skips hidden command palette results during keyboard navigation', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($view)
->toContain('filter(item => item.offsetParent !== null)');
});
@@ -14,6 +14,14 @@ test('listbox trigger styles constrain width and ellipsize long labels', functio
->toContain('white-space: nowrap;');
});
test('listbox trigger height matches shared inputs', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toMatch('/\.listbox-trigger \{[^}]*height: 2\.25rem;/s')
->toMatch('/\.application-settings-workspace \.listbox-trigger[^}]*height: 2rem;/s');
});
test('listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="longOption" label="Example"
@@ -19,3 +19,20 @@ it('dispatches a server navbar refresh after toggling sentinel', function () {
expect($matches['body'] ?? '')
->toContain("\$this->dispatch('refreshServerShow');");
});
it('only marks sentinel enabled after startup succeeds', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)\n \}/s', $componentSource, $matches);
$toggleBody = $matches['body'] ?? '';
expect(strpos($toggleBody, 'StartSentinel::run'))->toBeLessThan(
strpos($toggleBody, '$this->isSentinelEnabled = true;')
);
});
it('does not repeat a disabled status badge in the sentinel empty state', function () {
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
expect($view)->not->toContain("? 'Disabled'");
});
+13
View File
@@ -1,5 +1,18 @@
<?php
it('uses the themed warning color for resource action icons', function () {
foreach ([
'application',
'database',
'service',
] as $resource) {
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
expect(substr_count($heading, 'name="play-circle" class="size-3.5 text-warning"'))
->toBeGreaterThanOrEqual(2);
}
});
it('uses native mobile menus for databases and services', function () {
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
+39
View File
@@ -39,3 +39,42 @@ test('process dialog body styles support a full-height log surface', function ()
->toContain('.process-dialog-body')
->toContain('min-height: min(70dvh, 28rem)');
});
test('runtime log terminal fills the available dialog width', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
expect($view)->toContain("<div @class(['w-full min-w-0', 'runtime-log-shell' => \$collapsible])>");
});
test('process and log viewers use centered dialogs', function () {
$paths = [
resource_path('views/livewire/project/service/index.blade.php'),
resource_path('views/livewire/project/database/keydb/general.blade.php'),
resource_path('views/livewire/project/database/redis/general.blade.php'),
resource_path('views/livewire/project/database/postgresql/general.blade.php'),
resource_path('views/livewire/project/database/clickhouse/general.blade.php'),
resource_path('views/livewire/project/database/mongodb/general.blade.php'),
resource_path('views/livewire/project/database/dragonfly/general.blade.php'),
resource_path('views/livewire/project/database/mariadb/general.blade.php'),
resource_path('views/livewire/project/database/mysql/general.blade.php'),
resource_path('views/livewire/server/security/patches.blade.php'),
resource_path('views/livewire/server/cloudflare-tunnel.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('<x-process-dialog')
->not->toContain('<x-slide-over');
}
expect(file_get_contents(resource_path('views/livewire/server/show.blade.php')))
->not->toContain('<x-slide-over');
});
test('process dialog can start open for an in-progress operation', function () {
$component = file_get_contents(resource_path('views/components/process-dialog.blade.php'));
expect($component)
->toContain("'open' => false")
->toContain('processDialogOpen: @js($open)');
});
+40 -1
View File
@@ -11,7 +11,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
});
function setupProxyUser(string $role): array
@@ -72,6 +72,25 @@ test('admin can see proxy restart and stop buttons', function () {
->assertSee('Stop Proxy');
});
test('admin can stop a proxy while it is starting', function () {
[$user, $team, $server] = setupProxyUser('admin');
$server->proxy->status = 'starting';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertSee('Stop Proxy')
->assertDontSee('Start Proxy');
});
test('member cannot see start proxy button', function () {
[$user, $team, $server] = setupProxyUser('member');
@@ -89,3 +108,23 @@ test('member cannot see start proxy button', function () {
Livewire::test('server.navbar', ['server' => $mock])
->assertDontSee('Start Proxy');
});
test('start proxy button shows a loading state while proxy startup actions run', function () {
[$user, $team, $server] = setupProxyUser('admin');
$server->proxy->status = 'exited';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertSeeHtml('wire:loading.attr="disabled"')
->assertSeeHtml('wire:loading.class="is-loading"')
->assertSeeHtml('wire:target="checkProxy,startProxy"');
});
@@ -15,6 +15,18 @@ it('renders the resource terminal shell while containers are discovered', functi
->not->toContain('<x-loading text="Loading containers" />');
});
it('provides opt-in diagnostics for connected terminal theme changes', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('terminal-debug')
->toContain("'[Terminal Theme] Applying theme'")
->toContain("'[Terminal Theme] Theme applied'")
->toContain('requestedTheme: themeName')
->toContain('shellTheme: shell?.dataset.consoleTheme')
->toContain("getComputedStyle(shell, '::before').background");
});
it('starts a single discovered resource container without waiting for a missed browser event', function () {
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
@@ -171,11 +183,12 @@ it('mounts the realtime terminal utilities in local development compose files',
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
]);
it('keeps terminal browser logging restricted to Vite development mode', function () {
it('keeps terminal browser logging restricted to development or explicit diagnostics', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
->toContain('const terminalDebugEnabled = import.meta.env.DEV')
->toContain("localStorage.getItem('coolify-terminal-debug') === '1'")
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
@@ -1,14 +1,18 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
DB::table('instance_settings')->insert(['id' => 0]);
$user = User::factory()->create();
$this->team = $user->teams()->first();
@@ -146,6 +150,38 @@ describe('ServerSetting::ensureValidSentinelToken', function () {
});
});
describe('ServerSetting::ensureSentinelUrl', function () {
it('uses the current private instance URL when no public address is configured', function () {
InstanceSettings::query()->whereKey(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
Once::flush();
DB::table('server_settings')->where('id', $this->server->settings->id)->update(['sentinel_custom_url' => null]);
app()->instance('request', Request::create('http://192.168.1.50:8000/server'));
$url = $this->server->settings->fresh()->ensureSentinelUrl();
expect($url)->toBe('http://192.168.1.50:8000')
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
});
it('does not use a loopback request URL for a remote server', function () {
InstanceSettings::query()->whereKey(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
Once::flush();
DB::table('server_settings')->where('id', $this->server->settings->id)->update(['sentinel_custom_url' => null]);
app()->instance('request', Request::create('http://localhost:8000/server'));
expect(fn () => $this->server->settings->fresh()->ensureSentinelUrl())
->toThrow(RuntimeException::class, 'Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.');
});
});
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
@@ -0,0 +1,9 @@
<?php
test('server settings explain dedicated build server mode', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->toContain('label="Use as a dedicated build server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});
@@ -0,0 +1,20 @@
<?php
test('server creation uses the standard page title without redundant navigation', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)
->toContain('<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">New server</h1>')
->toContain('class="mb-5 flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"')
->not->toContain('Back to servers')
->not->toContain('title="Add a server"');
});
test('provider pages only show the token action in the account panel', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)
->not->toContain('New token')
->not->toContain('new-server-token-')
->not->toContain('tokenProviderName');
});
@@ -0,0 +1,20 @@
<?php
test('server creation keeps private key actions together and advanced options collapsed', function () {
$view = file_get_contents(resource_path('views/livewire/server/new/by-ip.blade.php'));
expect($view)
->toContain('class="flex items-end gap-3"')
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
->toContain('label="Use as a dedicated build server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});
test('server creation places the IP address and private key before optional details', function () {
$view = file_get_contents(resource_path('views/livewire/server/new/by-ip.blade.php'));
expect($view)
->toContain('class="mb-5"')
->and(strpos($view, 'id="ip"'))->toBeLessThan(strpos($view, 'id="private_key_id"'))
->and(strpos($view, 'id="private_key_id"'))->toBeLessThan(strpos($view, 'id="name"'));
});
@@ -20,3 +20,13 @@ test('view switchers use the shared coollabs selected state on every page', func
->toContain('@utility control-selected')
->toContain('@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;');
});
test('server index does not expose server IP addresses', function () {
$view = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
expect($view)
->not->toContain("'address' => \$server->ip")
->not->toContain('<div>Address</div>')
->not->toContain('x-text="server.address"')
->not->toContain('server.address,');
});
@@ -0,0 +1,10 @@
<?php
test('sentinel-required metrics state does not repeat an unavailable badge', function () {
$view = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
$sentinelRequiredState = str($view)->after('@else')->before('@endif')->toString();
expect($sentinelRequiredState)
->toContain('title="Sentinel is required"')
->not->toContain('status="Unavailable"');
});
@@ -55,3 +55,13 @@ test('unmanaged container names use the same typeface as managed resource names'
->toContain('min-w-0 truncate text-[12px] font-medium text-neutral-950 dark:text-fg')
->not->toContain('truncate font-mono text-[12px] text-neutral-950 dark:text-fg');
});
test('server resource tabs show a loading state while switching', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect(substr_count($view, 'wire:loading.attr="disabled" wire:target="loadManagedContainers,loadUnmanagedContainers"'))
->toBe(2)
->and($view)
->toContain('<x-loading-on-button wire:loading wire:target="loadManagedContainers" />')
->toContain('<x-loading-on-button wire:loading wire:target="loadUnmanagedContainers" />');
});
@@ -0,0 +1,56 @@
<?php
test('server revalidation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl" :open="$isValidating">')
->toContain(':isHighlighted="! $server->isFunctional()"')
->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"')
->not->toContain('<x-slide-over');
});
test('completed server validation shows a close action instead of empty logs', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)
->toContain('$validationComplete')
->toContain('mt-auto')
->toContain('<x-forms.button type="button" @click="processDialogOpen = false">')
->toContain('@click="processDialogOpen = false"')
->toContain('Validation complete')
->toContain('Close');
});
test('installation logs are only shown after an installation starts', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
$component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php'));
expect($view)->toContain('@elseif ($isInstalling)')
->and($component)
->toContain('public bool $isInstalling = false;')
->toContain('$this->isInstalling = true;');
});
test('server validation content scrolls within the dialog', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
$activityMonitor = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
expect($view)->toContain('class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scrollbar"')
->and($activityMonitor)->toContain("'overflow-hidden' => !\$fullHeight")
->and($activityMonitor)->not->toContain("'h-full overflow-hidden' => !\$fullHeight");
});
test('validation checkpoints use the standard bordered list treatment', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)
->toContain('data-validation-checkpoints')
->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]');
});
test('all validation checkpoints remain visible while only the current phase runs', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)->not->toContain("@continue(! \$checkpoint['visible'])");
});
@@ -54,3 +54,10 @@ it('distinguishes domain management from resource settings', function () {
->toContain('title="Resource settings" aria-label="Resource settings"')
->not->toContain('title="Edit domains" aria-label="Edit domains"');
});
it('does not display application domains on compose resource cards', function () {
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($resourceCard)
->not->toContain('{{ $resource->fqdn }}');
});
@@ -18,6 +18,12 @@ it('moves service and database page navigation into their sidebars', function ()
->toContain("['label' => 'Terminal'");
});
it('uses a full page load for database backup imports', function () {
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
expect($sidebar)->toContain("['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false");
});
it('matches application action bar behavior for services and databases', function () {
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$database = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
@@ -40,7 +46,7 @@ it('keeps database and service sidebar sections in the application sequence', fu
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
expect($database)
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup']")
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']")
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
+5 -3
View File
@@ -104,9 +104,11 @@ it('groups configured domains with their service redirect and excludes services
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("service-domain-redirect-{$this->apiApp->id}")
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("id=\"service-domain-redirect-{$this->apiApp->id}-trigger\"")
->toContain("serviceRedirects.{$this->apiApp->id}")
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->not->toContain("<select id=\"service-domain-redirect-{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
@@ -44,3 +44,31 @@ test('configuration sidebar subitems trigger section highlight on scroll', funct
->toContain("addEventListener('scrollend'")
->toContain('stableFrames');
});
test('postgresql general navigation lists each in-page settings section', function () {
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
$general = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
$sections = [
'database-details-section' => 'Database details',
'credentials-section' => 'Credentials',
'initialization-section' => 'Initialization',
'runtime-network-section' => 'Runtime and network',
'public-access-section' => 'Public access',
'configuration-section' => 'Configuration',
'log-delivery-section' => 'Log delivery',
'initialization-scripts-section' => 'Initialization scripts',
];
foreach ($sections as $id => $label) {
expect($sidebar)
->toContain("['id' => '{$id}', 'label' => '{$label}']")
->and($general)->toContain("id=\"{$id}\"");
}
expect($sidebar)
->toContain("\$database->type() === 'standalone-postgresql'")
->toContain('window.scrollToSettingsSection?.(id)')
->toContain("activeSection === '{{ \$section['id'] }}'")
->toContain("scrollToSection('{{ \$section['id'] }}')");
});
@@ -15,6 +15,14 @@ it('keeps server submenu state independent from the Livewire update route', func
->and($proxyLogs)->toContain('activeSubMenu="logs"');
});
it('keeps the server resources menu active during Livewire updates', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
expect($sidebar)
->toContain("'label' => 'Resources',\n 'route' => 'server.resources',\n 'active' => \$activeMenu === 'resources'")
->not->toContain("'active' => request()->routeIs('server.resources')");
});
it('initializes persisted sidebar state before enabling layout transitions', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
@@ -0,0 +1,7 @@
<?php
it('shows a loading indicator while creating the application', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/simple-dockerfile.blade.php'));
expect($view)->toContain('<x-forms.button type="submit" wire:target="submit" isHighlighted>');
});
+18 -1
View File
@@ -20,7 +20,7 @@ it('shows a centered themed target canvas before loading xterm', function () {
->toContain('data-terminal-target-canvas')
->toContain('items-center justify-center')
->toContain(':data-console-theme="consoleTheme"')
->toContain("@else\n <div data-terminal-session-canvas");
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
@@ -54,6 +54,23 @@ it('uses the same padded themed canvas for the active terminal session', functio
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s');
});
it('keeps pre-connection and connected terminal canvases in distinct Livewire DOM branches', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('wire:key="terminal-target-canvas"')
->toContain('wire:key="terminal-session-canvas"');
});
it('opens the global terminal outside Livewire navigation like resource terminals', function () {
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbar)
->toContain('<a title="Terminal"')
->toContain('href="{{ route(\'terminal\') }}"')
->not->toMatch('/<a title="Terminal"[^>]*wireNavigate\(\)/s');
});
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'));
+9
View File
@@ -0,0 +1,9 @@
<?php
test('deployment log timestamps omit microseconds', function () {
$helper = file_get_contents(dirname(__DIR__, 2).'/bootstrap/helpers/remoteProcess.php');
expect($helper)
->toContain("->format('Y-M-d H:i:s')")
->not->toContain("->format('Y-M-d H:i:s.u')");
});
@@ -0,0 +1,17 @@
<?php
test('sentinel startup regenerates an empty endpoint from instance settings', function () {
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
$component = file_get_contents(dirname(__DIR__, 2).'/app/Livewire/Server/Sentinel.php');
expect($action)
->toContain('ensureSentinelUrl()')
->and($component)
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;')
->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php'))
->toContain('generateSentinelUrl(ignoreEvent: true)')
->toContain('sentinelUrlFromCurrentRequest()')
->toContain('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.')
->and($component)
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
});
@@ -111,8 +111,9 @@ it('renders an automatically added redirect counterpart without reloading', func
$page = visit("{$base}/domains")
->assertSee('https://web.example.com');
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
$page->select($selector, 'www')
$selector = '#service-domain-redirect-'.$this->serviceApplication->id.'-trigger';
$page->click($selector)
->click('Redirect to www')
->wait(3);
$this->serviceApplication->refresh();