feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras BacsaiandShadowArcanist authored and GitHub committed 2026-08-03 23:11:54 +02:00
1 parent 0b843bb07c
commit 8ae56587d5
497 files changed
+44045 -22630

No files matched your search

@@ -0,0 +1,199 @@
<?php
use App\Http\Middleware\AddServerTimingHeaders;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
test('adds Server-Timing and debug headers when server_timing is enabled', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/__server-timing-probe', 'GET');
$response = $middleware->handle($request, function () {
DB::select('select 1');
return response('ok', 200);
});
expect($response->getStatusCode())->toBe(200);
expect($response->headers->has('Server-Timing'))->toBeTrue();
$serverTiming = $response->headers->get('Server-Timing');
expect($serverTiming)
->toContain('app;desc="Total";dur=')
->toContain('db;desc="Database (')
->toContain('php;desc="PHP (excl. DB)";dur=')
->toContain('dbslow;desc="Slowest query";dur=')
->toContain('queries;desc="Query count";dur=')
->toContain('html;desc="Response bytes";dur=')
->toContain('mem;desc="Peak memory (MB)";dur=');
expect($response->headers->has('X-Debug-Query-Count'))->toBeTrue();
expect((int) $response->headers->get('X-Debug-Query-Count'))->toBeGreaterThanOrEqual(1);
expect($serverTiming)->toMatch('/queries;desc="Query count";dur=\d+/');
expect($response->headers->has('X-Debug-Memory-MB'))->toBeTrue();
expect((float) $response->headers->get('X-Debug-Memory-MB'))->toBeGreaterThan(0);
expect($response->headers->has('X-Debug-Html-Bytes'))->toBeTrue();
expect((int) $response->headers->get('X-Debug-Html-Bytes'))->toBeGreaterThan(0);
expect($serverTiming)->toMatch('/html;desc="Response bytes";dur=\d+/');
});
test('injects on-screen HUD into full HTML documents when server_timing is enabled', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/projects', 'GET');
$response = $middleware->handle($request, function () {
DB::select('select 1');
return response('<!DOCTYPE html><html><body><main>projects</main></body></html>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
$body = $response->getContent();
expect($body)
->toContain('id="server-timing-hud"')
->toContain('data-metrics=')
->toContain('data-sth-log')
->toContain('data-sth-clear')
->toContain('data-sth-toggle')
->toContain('Server Timing')
->toContain('toggleOpen')
->toContain('formatEntryForAi')
->toContain('copyEntryById')
->toContain('__coolifyServerTimingHistory')
->toContain('</body>');
expect(substr_count($body, 'id="server-timing-hud"'))->toBe(1);
// Docks into main top bar via #server-timing-hud-slot; float fallback is bottom-left.
expect($body)
->toContain('dockHud')
->toContain('data-sth-mode="float"')
->toContain('position:fixed;bottom:12px;left:12px')
->not->toContain('position:fixed;bottom:12px;right:12px')
->not->toContain('position:fixed;top:56px;right:12px');
// Inline <script> must not embed literal HTML closers (browser ends the script early).
$hudScript = null;
if (preg_match('/id="server-timing-hud".*?<script data-navigate-once>(.*?)<\/script>/s', $body, $m)) {
$hudScript = $m[1];
}
expect($hudScript)->not->toBeNull();
expect($hudScript)
->not->toContain('</body>')
->not->toContain('</BODY>')
->not->toContain('</script>')
->not->toContain('</SCRIPT>');
});
test('removes stale Content-Length after injecting the HUD', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/projects', 'GET');
$html = '<!DOCTYPE html><html><body><main>projects</main></body></html>';
$response = $middleware->handle($request, function () use ($html) {
return response($html, 200, [
'Content-Type' => 'text/html; charset=UTF-8',
'Content-Length' => (string) strlen($html),
]);
});
expect($response->getContent())->toContain('id="server-timing-hud"');
expect($response->headers->has('Content-Length'))->toBeFalse();
});
test('app shell exposes Server-Timing HUD dock slot in the desktop top bar only', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('id="server-timing-hud-slot"')
->toContain('data-server-timing-hud-slot')
->not->toContain('id="server-timing-hud-slot-mobile"');
});
test('Server-Timing HUD docks on desktop only and floats on smaller viewports', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('server-timing-hud-slot')
->toContain("matchMedia('(min-width: 1024px)')")
->toContain('float bottom-left instead of docking')
->not->toContain('server-timing-hud-slot-mobile');
});
test('does not inject HUD into non-HTML or fragment responses', function () {
Config::set('app.server_timing', true);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/api/probe', 'GET');
$json = $middleware->handle($request, function () {
return response()->json(['ok' => true]);
});
expect($json->headers->has('Server-Timing'))->toBeTrue();
expect($json->getContent())->not->toContain('server-timing-hud');
$fragment = $middleware->handle($request, function () {
return response('<div wire:id="x">partial</div>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
expect($fragment->getContent())->not->toContain('server-timing-hud');
});
test('does not add Server-Timing headers when server_timing is disabled', function () {
Config::set('app.server_timing', false);
$middleware = new AddServerTimingHeaders;
$request = Request::create('/__server-timing-probe', 'GET');
$response = $middleware->handle($request, function () {
return response('<!DOCTYPE html><html><body>x</body></html>', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
});
expect($response)->toBeInstanceOf(Response::class);
expect($response->headers->has('Server-Timing'))->toBeFalse();
expect($response->headers->has('X-Debug-Query-Count'))->toBeFalse();
expect($response->headers->has('X-Debug-Memory-MB'))->toBeFalse();
expect($response->getContent())->not->toContain('server-timing-hud');
});
test('server_timing defaults off outside the local environment', function () {
// Mirror phpunit APP_ENV=testing — config is resolved at boot; re-evaluate the default rule.
expect(config('app.env'))->not->toBe('local');
// When SERVER_TIMING_ENABLED is unset, non-local envs must not enable headers by default.
// (phpunit does not set SERVER_TIMING_ENABLED; APP_ENV=testing)
expect(config('app.server_timing'))->toBeFalse();
});
test('SERVER_TIMING_ENABLED can force server_timing on or off regardless of APP_ENV', function () {
// Mirrors config/app.php resolution for app.server_timing.
$resolve = function (?string $enabled, string $appEnv): bool {
return $enabled !== null
? (bool) filter_var($enabled, FILTER_VALIDATE_BOOLEAN)
: $appEnv === 'local';
};
expect($resolve(null, 'local'))->toBeTrue();
expect($resolve(null, 'production'))->toBeFalse();
expect($resolve(null, 'testing'))->toBeFalse();
// Force on in production / staging.
expect($resolve('true', 'production'))->toBeTrue();
expect($resolve('1', 'production'))->toBeTrue();
expect($resolve('yes', 'staging'))->toBeTrue();
// Force off even in local.
expect($resolve('false', 'local'))->toBeFalse();
expect($resolve('0', 'local'))->toBeFalse();
expect($resolve('off', 'local'))->toBeFalse();
});
@@ -0,0 +1,52 @@
<?php
/**
* Advanced navigation and action menus must share the same reicon ("grid").
*/
test('advanced sidebar and configuration menus use the grid icon', function () {
$files = [
resource_path('views/components/settings/sidebar.blade.php'),
resource_path('views/components/server/sidebar.blade.php'),
resource_path('views/components/service-database/sidebar.blade.php'),
resource_path('views/livewire/project/service/index.blade.php'),
resource_path('views/livewire/project/application/configuration.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)->toContain('Advanced');
// PHP menu arrays: label Advanced paired with icon grid
if (str_contains($contents, "'label' => 'Advanced'") || str_contains($contents, "'Advanced' =>")) {
expect($contents)
->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'grid'|'Advanced'\\s*=>\\s*'grid'/")
->not->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'(?!grid)[^']+'/")
->not->toMatch("/'Advanced'\\s*=>\\s*'(?!grid)[^']+'/");
}
// Inline blade markup: reicon grid near Advanced label
if (str_contains($contents, 'menu-item-label">Advanced')) {
expect($contents)
->toMatch('/name="grid"[\\s\\S]{0,120}menu-item-label">Advanced/')
->not->toMatch('/name="(?!grid)[^"]+"[\\s\\S]{0,120}menu-item-label">Advanced/');
}
}
});
test('advanced action dropdown menus use the grid icon', function () {
$files = [
resource_path('views/components/applications/advanced.blade.php'),
resource_path('views/components/services/advanced.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->toContain('Advanced')
->toContain('name="grid"')
->not->toContain('name="sliders"')
->not->toContain("name='sliders'");
}
});
@@ -0,0 +1,9 @@
<?php
test('api tokens revoke action is text-only without a trash icon', function () {
$blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
expect($blade)
->toContain('Revoke')
->not->toMatch('/name="trash"[\s\S]{0,80}Revoke|Revoke[\s\S]{0,80}name="trash"/');
});
@@ -0,0 +1,104 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
/**
* Regression for #11079: removing all domains must save without TypeError from
* sslipDomainWarning(null) after normalizeApplicationDomains returns null.
*/
test('can clear application domains and save successfully', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'fqdn' => 'https://example.com',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('fqdn', null)
->call('submit')
->assertHasNoErrors()
->assertNotDispatched('error')
->assertDispatched('success');
$application->refresh();
expect($application->fqdn)->toBeNull();
});
test('can clear application domains via empty string and save successfully', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'fqdn' => 'https://app.example.com,https://www.example.com',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('fqdn', '')
->call('submit')
->assertHasNoErrors()
->assertNotDispatched('error')
->assertDispatched('success');
$application->refresh();
expect($application->fqdn)->toBeNull();
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('uses card-style git source triggers instead of plain form buttons', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/source.blade.php'));
expect($view)
->toContain('<x-slot:trigger>')
->toContain('label="Current"')
->toContain('aria-current="true"')
->toContain('<x-git-icon')
->toContain('rounded-xl border')
->not->toContain('<x-slot:customButton>');
});
it('renders the current source as a selected card and alternatives as switchable cards', function () {
$current = GithubApp::create([
'name' => 'coolify-laravel-dev-public',
'organization' => 'coollabsio',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 111,
'installation_id' => 222,
'client_id' => 'client',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
]);
$other = GithubApp::create([
'name' => 'coolify-examples-app',
'organization' => 'coollabsio',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 333,
'installation_id' => 444,
'client_id' => 'client-2',
'client_secret' => 'secret-2',
'webhook_secret' => 'webhook-2',
]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'private_key_id' => null,
'source_id' => $current->id,
'source_type' => GithubApp::class,
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
]);
Livewire::test(Source::class, ['application' => $application])
->assertOk()
->assertSee('Git source', false)
->assertSee('coolify-laravel-dev-public', false)
->assertSee('Current', false)
->assertSee('GitHub · coollabsio', false)
->assertSee('coolify-examples-app', false)
->assertSee('Change Git source?', false);
});
@@ -0,0 +1,114 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
function createApplicationForHttpBasicAuth(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'admin',
'http_basic_auth_password' => 'secret',
'redirect' => 'no',
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'ports_exposes' => '3000',
], $overrides));
}
test('http basic auth form shows a single username and password without multi-user controls', function () {
$application = createApplicationForHttpBasicAuth();
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSet('isHttpBasicAuthEnabled', true)
->assertSet('httpBasicAuthUsername', 'admin')
->assertSee('Username')
->assertSee('Password')
->assertDontSee('Add user')
->assertDontSee('Remove user')
->assertDontSee('The default user cannot be removed');
});
test('http basic auth credentials can be saved through the general form', function () {
$application = createApplicationForHttpBasicAuth([
'http_basic_auth_username' => 'old-user',
'http_basic_auth_password' => 'old-pass',
]);
Livewire::test(General::class, ['application' => $application])
->set('httpBasicAuthUsername', 'new-user')
->set('httpBasicAuthPassword', 'new-pass')
->call('submit')
->assertHasNoErrors();
$application->refresh();
expect($application->http_basic_auth_username)->toBe('new-user')
->and($application->http_basic_auth_password)->toBe('new-pass')
->and((bool) $application->is_http_basic_auth_enabled)->toBeTrue();
});
test('disabling http basic auth is saved instantly', function () {
$application = createApplicationForHttpBasicAuth();
Livewire::test(General::class, ['application' => $application])
->set('isHttpBasicAuthEnabled', false)
->call('instantSave')
->assertHasNoErrors()
->assertSet('isHttpBasicAuthEnabled', false);
$application->refresh();
expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse();
});
+81 -6
View File
@@ -1,17 +1,32 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Application\Domains;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
[
'id' => 0,
'is_dns_validation_enabled' => false,
]
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -19,6 +34,33 @@ beforeEach(function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -27,11 +69,13 @@ describe('Application Redirect', function () {
test('setRedirect persists the redirect value to the database', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://example.com,https://www.example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
@@ -41,20 +85,51 @@ describe('Application Redirect', function () {
expect($application->redirect)->toBe('www');
});
test('setRedirect rejects www redirect when no www domain exists', function () {
test('setRedirect auto-adds missing www domain instead of rejecting', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('error');
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('both');
expect($application->redirect)->toBe('www')
->and(explode(',', (string) $application->fqdn))
->toContain('https://example.com')
->toContain('https://www.example.com');
});
test('setRedirect only treats hostname-leading www as www and auto-adds the real pair', function (string $fqdn, string $expectedWww) {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => $fqdn,
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('www')
->and(explode(',', (string) $application->fqdn))
->toContain($fqdn)
->toContain($expectedWww);
})->with([
'www in path' => ['https://example.com/www.example.com', 'https://www.example.com/www.example.com'],
'www in unrelated hostname label' => ['https://app.www.example.com', 'https://www.app.www.example.com'],
]);
});
@@ -0,0 +1,16 @@
<?php
/**
* Application configuration Rollback nav must use the reicon "time-back" glyph.
*/
test('application rollback menu uses the time-back icon', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($configuration)
->toMatch("/'Rollback'\\s*=>\\s*'time-back'/")
->not->toMatch("/'Rollback'\\s*=>\\s*'logout'/");
expect($reicon)
->toContain("'time-back' =>");
});
@@ -1,5 +1,6 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -7,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->personal()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -59,7 +61,8 @@ it('renders 419 error page with login link instead of previous url', function ()
$view = view('errors.419')->render();
expect($view)->toContain('/login');
expect($view)->toContain('Back to Login');
expect($view)->toContain('Back to login');
expect($view)->toContain('This page is definitely old, not like you!');
expect($view)->toContain('error-shell');
expect($view)->not->toContain('url()->previous()');
});
@@ -164,7 +164,7 @@ test('admin dev view shows unlocked env value', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
});
@@ -175,7 +175,7 @@ test('admin dev view hides locked env value', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)');
expect($component->get('variables'))->not->toContain('secret-locked-value');
@@ -187,7 +187,7 @@ test('member dev view hides all env values', function () {
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
])->call('switch');
expect($component->get('variables'))->not->toContain('secret-unlocked-value');
expect($component->get('variables'))->not->toContain('secret-locked-value');
@@ -10,7 +10,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->owner = User::factory()->create();
$this->personalTeam = $this->owner->teams()->first();
@@ -44,3 +44,16 @@ test('member cannot delete team via navbar', function () {
expect(Team::find($this->teamToDelete->id))->not->toBeNull();
});
test('navbar delete team trigger matches sidebar menu item layout', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->assertSuccessful()
->assertSee('title="Delete Team"', false)
->assertSee('menu-item justify-start text-left', false)
->assertSee('sidebar-collapsed-label', false)
->assertSee('menu-item-icon', false)
->assertDontSeeHtml('buttonTitle="Delete Team"');
});
@@ -11,7 +11,10 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create(['personal_team' => false]);
@@ -138,6 +141,21 @@ test('owner can update team MCP setting', function () {
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
});
test('team index mounts when is_mcp_server_enabled is null on the session team', function () {
$this->actingAs($this->owner);
// Simulate Team::create() without hydrating the DB default into session.
$this->team->setRawAttributes(array_merge(
$this->team->getAttributes(),
['is_mcp_server_enabled' => null],
));
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->assertSuccessful()
->assertSet('is_mcp_server_enabled', true);
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
+67 -5
View File
@@ -171,7 +171,7 @@ it('splits standalone database backup settings and executions across dedicated u
$this->get($generalUrl.'/s3')
->assertOk()
->assertSeeText('No validated S3 available. Configure one here.')
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
@@ -264,6 +264,68 @@ it('redirects to executions after queuing a database backup with unusable S3 sto
Queue::assertPushed(DatabaseBackupJob::class);
});
it('queues instance database backup without redirecting when project context is missing', function () {
Queue::fake();
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$this->user->teams()->syncWithoutDetaching([$rootTeam->id => ['role' => 'owner']]);
session(['currentTeam' => $rootTeam]);
$server = Server::factory()->create([
'id' => 0,
'team_id' => $rootTeam->id,
'ip' => '127.0.0.1',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::create([
'id' => 0,
'name' => 'coolify',
'uuid' => (string) str()->uuid(),
'network' => 'coolify',
'server_id' => $server->id,
]);
$database = new StandalonePostgresql;
$database->forceFill([
'id' => 0,
'name' => 'coolify-db',
'description' => 'Coolify database',
'postgres_user' => 'coolify',
'postgres_password' => 'password',
'postgres_db' => 'coolify',
'status' => 'running',
'destination_type' => StandaloneDocker::class,
'destination_id' => $destination->id,
'environment_id' => null,
]);
$database->save();
expect($database->project())->toBeNull()
->and($database->environment)->toBeNull();
$backup = ScheduledDatabaseBackup::create([
'id' => 0,
'enabled' => true,
'save_s3' => false,
'frequency' => '0 0 * * *',
'database_type' => StandalonePostgresql::class,
'database_id' => $database->id,
'team_id' => $rootTeam->id,
'timeout' => 3600,
]);
Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => collect(),
])
->call('backupNow')
->assertDispatched('success', 'Backup queued. It will be available in a few minutes.')
->assertNoRedirect()
->assertHasNoErrors();
Queue::assertPushed(DatabaseBackupJob::class);
});
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
@@ -366,10 +428,10 @@ it('shows only an empty S3 state when no storages are available', function () {
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSeeHtml('<h2>S3</h2>')
->assertSeeText('No validated S3 available. Configure one here.')
->assertSeeHtml('<h2>S3 storage</h2>')
->assertSeeText('No validated S3 storage')
->assertSeeHtml('href="'.route('storage.index').'"')
->assertSeeHtml('>here</a>')
->assertSeeText('Open S3 storage')
->assertDontSee('Save')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
@@ -390,7 +452,7 @@ it('allows S3 backups to be disabled when no usable storage remains', function (
'section' => 's3',
])
->assertSet('saveS3', true)
->assertSeeText('No validated S3 available. Configure one here.')
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable S3')
->call('toggleS3')
->assertDispatched('success')
+19 -9
View File
@@ -49,11 +49,19 @@ it('renders frontend-only application backup search data for volume names and fr
$this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache']))
->assertOk()
->assertSee('Cache-Data')
->assertSee('Volume: app-data')
->assertSee('app-data')
->assertSee("search: 'cache'", false)
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No scheduled backups match your search.');
->assertSee('No scheduled backups match your search.')
->assertSee('Filter')
->assertSee('Sort')
->assertSee('All targets')
->assertSee('Target A–Z')
->assertSee('filterOpen')
->assertSee('sortOpen')
->assertDontSee('backup-type-filter-trigger', false)
->assertDontSee('backup-sort-trigger', false);
});
it('renders frontend-only database backup search data for database names and frequencies', function () {
@@ -100,15 +108,15 @@ it('renders frontend-only database backup search data for database names and fre
$this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3']))
->assertOk()
->assertSee('<h3 class="font-semibold">0 3 * * 1</h3>', false)
->assertSee('<h3 class="font-semibold">daily</h3>', false)
->assertSee('S3: Archive-Bucket')
->assertSee('0 3 * * 1')
->assertSee('daily')
->assertSee('Archive-Bucket')
->assertSee('archive-bucket', false)
->assertSee('backup.s3_storage.includes(query)', false)
->assertSee('Search by database name, frequency, or S3 storage...')
->assertSee('Search backup schedules')
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No scheduled backups match your search.');
->assertSee('No matching backup schedules');
});
it('renders unavailable S3 storage only for S3-enabled database backups', function () {
@@ -147,9 +155,11 @@ it('renders unavailable S3 storage only for S3-enabled database backups', functi
$response = $this->get(route('project.database.backup.index', $parameters))
->assertOk()
->assertSee('S3: Storage unavailable');
->assertSee('Unavailable')
->assertSee('Local only');
expect(substr_count($response->getContent(), 'S3:'))->toBe(1);
// Only the S3-enabled schedule without a linked storage shows "Unavailable".
expect(substr_count($response->getContent(), 'Unavailable'))->toBe(1);
});
function createBackupSearchApplication(Team $team): Application
@@ -0,0 +1,36 @@
<?php
/**
* Terminal-related UI must use the redesign "browser-terminal" reicon, not the
* older "terminal" (four-way arrows) glyph.
*/
test('terminal and console views use the browser-terminal icon', function () {
$files = [
resource_path('views/components/navbar.blade.php'),
resource_path('views/components/backup-sidebar.blade.php'),
resource_path('views/components/server/sidebar-security.blade.php'),
resource_path('views/livewire/terminal/index.blade.php'),
resource_path('views/livewire/project/shared/terminal.blade.php'),
resource_path('views/livewire/project/shared/execute-container-command.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/all.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'),
resource_path('views/livewire/project/database/backup-executions.blade.php'),
resource_path('views/livewire/server/security/terminal-access.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->not->toMatch('/name=["\']terminal["\']/')
->not->toMatch('/icon-name=["\']terminal["\']/')
->not->toMatch("/'icon'\\s*=>\\s*'terminal'/");
}
});
test('reicon pack includes browser-terminal glyph', function () {
$path = resource_path('views/components/reicon.blade.php');
$contents = file_get_contents($path);
expect($contents)->toContain("'browser-terminal' =>");
});
@@ -0,0 +1,127 @@
<?php
use App\Support\DomainConnect\CloudflareDomainConnect;
function domainConnectTestKeyPair(): array
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
$details = openssl_pkey_get_details($key);
return [
'private' => $privateKey,
'public' => $details['key'],
];
}
beforeEach(function () {
$pair = domainConnectTestKeyPair();
$this->privateKey = $pair['private'];
$this->publicKey = $pair['public'];
config([
'constants.coolify.self_hosted' => false, // isCloud()
'services.domain_connect.provider_id' => 'coolify.io',
'services.domain_connect.service_id' => 'hosting',
'services.domain_connect.key_id' => '_dcpubkeyv1',
'services.domain_connect.private_key' => $this->privateKey,
]);
});
it('is configured when a private key is present', function () {
$connect = new CloudflareDomainConnect;
expect($connect->isConfigured())->toBeTrue();
config(['services.domain_connect.private_key' => null]);
expect((new CloudflareDomainConnect)->isConfigured())->toBeFalse();
});
it('builds a signed cloudflare domain connect apply url like resend', function () {
$connect = new CloudflareDomainConnect;
$url = $connect->buildHostingApplyUrl(
domain: 'example.com',
ip: '203.0.113.10',
host: 'app',
);
expect($url)
->toStartWith('https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/coolify.io/services/hosting/apply?')
->toContain('domain=example.com')
->toContain('host=app')
->toContain('ip=203.0.113.10')
->toContain('key=_dcpubkeyv1')
->toContain('sig=');
$query = parse_url($url, PHP_URL_QUERY);
parse_str($query, $params);
expect($params)
->toHaveKey('domain')
->toHaveKey('host')
->toHaveKey('ip')
->toHaveKey('key')
->toHaveKey('sig');
// Signature must be last query parameter (Cloudflare requirement).
expect(str_ends_with($query, 'sig='.rawurlencode($params['sig'])) || str_ends_with($query, 'sig='.$params['sig']))
->toBeTrue();
// Verify RSA-SHA256 over the query excluding key and sig.
$parts = [];
foreach (explode('&', $query) as $part) {
if (str_starts_with($part, 'key=') || str_starts_with($part, 'sig=')) {
continue;
}
$parts[] = $part;
}
$signedPayload = implode('&', $parts);
$ok = openssl_verify(
$signedPayload,
base64_decode($params['sig'], true),
$this->publicKey,
OPENSSL_ALGO_SHA256,
);
expect($ok)->toBe(1);
});
it('includes empty host for apex domains', function () {
$connect = new CloudflareDomainConnect;
$url = $connect->buildHostingApplyUrl(
domain: 'example.com',
ip: '203.0.113.10',
host: null,
);
expect($url)->toContain('host=');
});
it('splits hostnames into domain and host', function () {
expect(CloudflareDomainConnect::splitHostname('example.com'))
->toBe(['domain' => 'example.com', 'host' => ''])
->and(CloudflareDomainConnect::splitHostname('app.example.com'))
->toBe(['domain' => 'example.com', 'host' => 'app'])
->and(CloudflareDomainConnect::splitHostname('https://api.staging.example.com/path'))
->toBe(['domain' => 'example.com', 'host' => 'api.staging'])
->and(CloudflareDomainConnect::splitHostname('app.example.co.uk'))
->toBe(['domain' => 'example.co.uk', 'host' => 'app'])
->and(CloudflareDomainConnect::splitHostname('api.staging.example.com.au'))
->toBe(['domain' => 'example.com.au', 'host' => 'api.staging']);
});
it('rejects invalid domains and ips', function () {
$connect = new CloudflareDomainConnect;
expect(fn () => $connect->buildHostingApplyUrl(domain: 'not-a-domain', ip: '203.0.113.10'))
->toThrow(InvalidArgumentException::class)
->and(fn () => $connect->buildHostingApplyUrl(domain: 'example.com', ip: 'not-an-ip'))
->toThrow(InvalidArgumentException::class);
});
+177
View File
@@ -0,0 +1,177 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Dashboard\ActiveDeployments;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
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->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'name' => 'Dashboard App',
'status' => 'running',
]);
});
function createDashboardDeployment(array $overrides = []): ApplicationDeploymentQueue
{
return ApplicationDeploymentQueue::create(array_merge([
'application_id' => test()->application->id,
'application_name' => test()->application->name,
'deployment_uuid' => 'deploy-'.fake()->unique()->uuid(),
'deployment_url' => '/deployments/'.fake()->uuid(),
'server_id' => test()->server->id,
'server_name' => test()->server->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'pull_request_id' => 0,
], $overrides));
}
it('hides the deployments section when there is nothing to show', function () {
Livewire::test(ActiveDeployments::class)
->assertDontSee('Active and recent deployment activity')
->assertDontSee('Running or queued right now')
->assertDontSee('Latest completed deployments')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows only the active section when there are active deployments', function () {
createDashboardDeployment([
'application_name' => 'Running Now',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Deployments')
->assertSee('Running or queued right now')
->assertSee('Running Now')
->assertDontSee('Latest completed deployments')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows only the recent section when there are recent deployments', function () {
createDashboardDeployment([
'application_name' => 'Finished Earlier',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Deployments')
->assertSee('Latest completed deployments')
->assertSee('Finished Earlier')
->assertDontSee('Running or queued right now')
->assertDontSee('No active deployments')
->assertDontSee('No recent deployments');
});
it('shows up to five active deployments above recent ones', function () {
foreach (range(1, 6) as $index) {
createDashboardDeployment([
'application_name' => "Active App {$index}",
'status' => $index % 2 === 0
? ApplicationDeploymentStatus::QUEUED->value
: ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
}
foreach (range(1, 6) as $index) {
createDashboardDeployment([
'application_name' => "Recent App {$index}",
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
}
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments'))->toHaveCount(5)
->and($component->get('recentDeployments'))->toHaveCount(5);
$component
->assertSee('Active App')
->assertSee('Recent App')
->assertSee('Success')
->assertSee('In progress')
->assertSee('Queued')
->assertDontSee('Active App 6');
});
it('does not include active deployments in the recent list', function () {
createDashboardDeployment([
'application_name' => 'Running Now',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
createDashboardDeployment([
'application_name' => 'Finished Earlier',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
createDashboardDeployment([
'application_name' => 'Failed Earlier',
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments')->pluck('application_name')->all())
->toBe(['Running Now'])
->and($component->get('recentDeployments')->pluck('application_name')->all())
->toEqualCanonicalizing(['Finished Earlier', 'Failed Earlier']);
$component
->assertSee('Running Now')
->assertSee('Finished Earlier')
->assertSee('Failed Earlier')
->assertSee('Failed');
});
it('only includes deployments for servers owned by the current team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
createDashboardDeployment([
'application_name' => 'Team Deployment',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'application_name' => 'Other Team Deployment',
'deployment_uuid' => 'deploy-other-'.fake()->uuid(),
'deployment_url' => '/deployments/other',
'server_id' => $otherServer->id,
'server_name' => $otherServer->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'pull_request_id' => 0,
]);
Livewire::test(ActiveDeployments::class)
->assertSee('Team Deployment')
->assertDontSee('Other Team Deployment');
});
@@ -0,0 +1,20 @@
<?php
test('settings navbar uses a short email tab label', function () {
$path = resource_path('views/components/dashboard/navbar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain("['label' => 'Email', 'route' => 'settings.email'")
->not->toContain("['label' => 'Transactional Email', 'route' => 'settings.email'");
});
test('dashboard navbar keeps the original side-by-side tab and actions layout', function () {
$path = resource_path('views/components/dashboard/navbar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('flex w-full items-center justify-between gap-4 lg:h-full')
->toContain('flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto')
->not->toContain('flex w-full flex-col gap-3 lg:h-full lg:flex-row');
});
@@ -0,0 +1,107 @@
<?php
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('shows an add private key button when no private keys exist', function () {
Livewire::test(Dashboard::class)
->assertSee('A private key is required')
->assertSee('Add an SSH private key before connecting your first server.')
->assertSee('Add private key')
->assertSee(route('security.private-key.index'), false);
});
it('shows a new server button when private keys exist but servers do not', function () {
PrivateKey::factory()->create([
'team_id' => $this->team->id,
'name' => 'Deploy Key',
]);
Livewire::test(Dashboard::class)
->assertSee('No servers yet')
->assertSee('Connect infrastructure for your deployments.')
->assertSee('New server')
->assertSee(route('server.create'), false)
->assertDontSee('A private key is required')
->assertDontSee('Add private key');
});
it('does not show server empty state when servers exist', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => 'Production Host',
]);
Livewire::test(Dashboard::class)
->assertSee('Production Host')
->assertDontSee('A private key is required')
->assertDontSee('No servers yet');
});
it('renders clickable server cards without a decorative arrow control', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => 'Production Host',
]);
Livewire::test(Dashboard::class)
->assertSee('Production Host')
->assertSeeHtml('aria-label="Open Production Host"')
->assertSeeHtml(route('server.show', ['server_uuid' => $server->uuid]))
// Decorative arrow used z-10 over the full-card link and blocked clicks.
->assertDontSeeHtml('group-hover:bg-neutral-100 group-hover:text-black dark:text-fg-faint dark:group-hover:bg-white/[0.06]');
});
it('keeps resource grid cards free of decorative open chevrons', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain('absolute inset-0 rounded-xl')
->toContain('aria-label="`Open ${item.name}`"')
// Decorative card chevrons used a dedicated size-7 open control.
->not->toContain('flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg');
});
it('hides the add private key button for members without create permission', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Dashboard::class)
->assertSee('A private key is required')
->assertDontSee('Add private key');
});
@@ -230,6 +230,34 @@ it('reloads the mysql status-info model when refresh is called so ssl controls f
->assertSee('Database should be stopped to change this settings.');
});
it('shows ssl mode even when ssl is disabled, as a disabled control', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'ssl-disabled-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'enable_ssl' => false,
'ssl_mode' => 'REQUIRED',
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertSet('enableSsl', false)
->assertSee('SSL mode')
->assertSeeHtml('id="sslMode-trigger"');
});
it('does not clobber server form text inputs when sentinel restarts', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
+131 -4
View File
@@ -69,11 +69,138 @@ it('renders deployment logs in a full-height layout', function () {
$response->assertSuccessful();
$response->assertSee('rolling update started');
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
$response->assertSee('logs-viewer', false);
$response->assertSee('logs-viewer-toolbar', false);
$response->assertSee('logs-viewer-search', false);
$response->assertSee('logs-viewer-actions', false);
$response->assertSee('logs-viewer-viewport', false);
$response->assertSee('logs-viewer-line', false);
$response->assertSee('h-[calc(100dvh-7.5rem)]', false);
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
$response->assertSee('mt-4 flex flex-1 min-h-0 flex-col overflow-hidden', false);
$response->assertSee('flex min-h-0 flex-col w-full overflow-hidden bg-white', false);
$response->assertSee('flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar', false);
expect($response->getContent())->not->toContain('max-h-[30rem]');
});
it('uses a mobile-friendly stacked logs toolbar markup', function () {
$deploymentView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($deploymentView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-end')
->toContain('logs-viewer-status-badge')
->toContain('logs-viewer-line')
->toContain('logs-viewer-timestamp')
->toContain('logs-viewer-line-text')
->toContain('livewire:project.application.deployment-navbar')
->and($sharedLogsView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-lines')
->toContain('logs-viewer-actions')
->toContain('logs-viewer-line')
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($deploymentView)
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($appCss)
->toContain('.logs-viewer-toolbar')
->toContain('.logs-viewer-end')
->toContain('.logs-viewer-status-badge')
->toContain('.logs-viewer-lines')
->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section')
->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)');
// Status badge lives in the right-side end group, immediately before cancel controls.
$endGroup = str($deploymentView)
->after('class="logs-viewer-end"')
->before('logs-viewer-viewport')
->toString();
expect($endGroup)
->toContain('logs-viewer-status-badge')
->toContain('livewire:project.application.deployment-navbar');
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
$navbarPos = strpos($endGroup, 'livewire:project.application.deployment-navbar');
expect($badgePos)->not->toBeFalse()
->and($navbarPos)->not->toBeFalse()
->and($badgePos)->toBeLessThan($navbarPos);
});
it('places cancel deployment controls inside the deployment logs toolbar', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deploy-cancel-toolbar-test',
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'logs' => json_encode([
[
'command' => null,
'output' => 'building image',
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
],
], JSON_THROW_ON_ERROR),
]);
$response = $this->get(route('project.application.deployment.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
$response->assertSuccessful();
$response->assertSee('Cancel deployment');
$response->assertSee('logs-viewer-end', false);
$response->assertSee('logs-viewer-deployment-actions', false);
$response->assertSee('logs-viewer-cancel-btn', false);
$content = $response->getContent();
$toolbarPos = strpos($content, 'logs-viewer-toolbar');
$endPos = strpos($content, 'logs-viewer-end');
$viewportPos = strpos($content, 'logs-viewer-viewport');
expect($toolbarPos)->not->toBeFalse()
->and($endPos)->not->toBeFalse()
->and($viewportPos)->not->toBeFalse()
->and($endPos)->toBeGreaterThan($toolbarPos)
->and($endPos)->toBeLessThan($viewportPos);
// Within the right-side end group: status badge, then cancel controls.
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
$cancelPos = strpos($endGroup, 'Cancel deployment');
expect($badgePos)->not->toBeFalse()
->and($cancelPos)->not->toBeFalse()
->and($badgePos)->toBeLessThan($cancelPos)
->and($endGroup)->toContain('In progress');
});
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
$proxyLogsView = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
$sentinelLogsView = file_get_contents(resource_path('views/livewire/server/sentinel/logs.blade.php'));
expect($proxyLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs')
->and($sentinelLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs');
});
@@ -0,0 +1,88 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\DeploymentsIndicator;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
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->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'name' => 'Indicator App',
'status' => 'running',
]);
ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'application_name' => $this->application->name,
'deployment_uuid' => 'deploy-indicator-'.fake()->uuid(),
'deployment_url' => '/deployments/indicator',
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'pull_request_id' => 0,
]);
});
it('hides the floating deployments indicator on the dashboard', function () {
$this->get(route('dashboard'))
->assertSuccessful()
->assertDontSee('1 deployment', false)
->assertDontSee('aria-label="Active deployments"', false);
});
it('shows the floating deployments indicator on non-dashboard pages', function () {
$this->get(route('project.index'))
->assertSuccessful()
->assertSee('1 deployment', false)
->assertSee('aria-label="Active deployments"', false);
});
it('keeps the indicator hidden across polls after mounting on the dashboard', function () {
$component = Livewire::test(DeploymentsIndicator::class)
->set('shouldShow', false)
->assertDontSee('1 deployment');
// Polls call refresh methods without remounting; visibility must stay sticky.
$component
->call('$refresh')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment');
});
it('updates visibility from the browser path after navigation', function () {
Livewire::test(DeploymentsIndicator::class)
->assertSet('shouldShow', true)
->call('updateShouldShowFromPath', '/')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment')
->call('updateShouldShowFromPath', '/projects')
->assertSet('shouldShow', true)
->assertSee('1 deployment');
});
@@ -16,3 +16,26 @@ it('positions the deployments indicator from the sidebar collapsed state', funct
expect(strpos($layoutView, '<div x-data="{'))
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
});
it('uses the redesigned elevated surface and status badge patterns', function () {
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
expect($indicatorView)
->toContain('var(--coollabs-elevated)')
->toContain('var(--coollabs-line)')
->toContain('var(--shadow-modal)')
->toContain('var(--coollabs-hairline)')
->toContain('<x-status-badge')
->toContain('name="chevron-down"')
->toContain("'In progress'")
->toContain("'Queued'")
->toContain('dark:text-fg')
->toContain('dark:border-white/[0.08]')
->toContain('$this->shouldShow')
->toContain('updateShouldShowFromPath')
->toContain('livewire:navigated')
->not->toContain('dark:bg-coolgray-100')
->not->toContain('dark:bg-coolgray-200')
->not->toContain('text-gray-800')
->not->toContain("str_replace('_', ' ', \$deployment->status)");
});
+45
View File
@@ -0,0 +1,45 @@
<?php
use App\Support\DnsRecordHints;
it('builds a and aaaa records for a single hostname', function () {
$records = DnsRecordHints::forTarget('app.example.com', '203.0.113.10', '2001:db8::1');
expect($records)->toHaveCount(2)
->and($records[0])->toMatchArray([
'type' => 'A',
'name' => 'app.example.com',
'value' => '203.0.113.10',
])
->and($records[1])->toMatchArray([
'type' => 'AAAA',
'name' => 'app.example.com',
'value' => '2001:db8::1',
]);
});
it('builds entries for every hostname without duplicates', function () {
$records = DnsRecordHints::forHostnames([
'app.example.com',
'www.example.com',
'app.example.com',
'https://api.example.com/path',
], '203.0.113.10');
expect($records)->toHaveCount(3)
->and(collect($records)->pluck('name')->all())->toBe([
'api.example.com',
'app.example.com',
'www.example.com',
])
->and(collect($records)->pluck('type')->unique()->all())->toBe(['A']);
});
it('formats a copy-paste text block with all entries', function () {
$text = DnsRecordHints::toCopyText([
['type' => 'A', 'name' => 'app.example.com', 'value' => '203.0.113.10'],
['type' => 'A', 'name' => 'www.example.com', 'value' => '203.0.113.10'],
]);
expect($text)->toBe("Type\tName\tValue\nA\tapp.example.com\t203.0.113.10\nA\twww.example.com\t203.0.113.10");
});
@@ -0,0 +1,66 @@
<?php
it('provides a reusable domain chips form component', function () {
$component = file_get_contents(resource_path('views/components/forms/domain-chips.blade.php'));
expect($component)
->toContain('chip-input')
->toContain('@entangle($model)')
->toContain('class="chip"')
->toContain('chip-remove');
});
it('uses domain chips for application public access and compose services', function () {
$blade = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($blade)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain(':model="$composeDomainKey"')
->not->toContain('id="parsedServiceDomains.');
});
it('uses domain chips for service application domains and edit domain modal', function () {
$serviceIndex = file_get_contents(resource_path('views/livewire/project/service/index.blade.php'));
$editDomain = file_get_contents(resource_path('views/livewire/project/service/edit-domain.blade.php'));
expect($serviceIndex)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain('Make publicly available')
->toContain('enablePublicAccess')
->toContain('disablePublicAccess')
->toContain("x-bind:disabled=\"!String(port ?? '').trim()\"")
->toContain('sm:flex-row sm:items-end')
->not->toContain('label="Make it publicly available"')
// Nested @if inside <x-forms.button> breaks Blade component compilation (ParseError endif).
->not->toContain('@if ($canTogglePublicAccess)');
expect($editDomain)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->not->toContain('id="fqdn"');
});
it('styles icon buttons with a visible hover state', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($utilities)
->toContain('@utility icon-button')
->toContain('hover:bg-neutral-100')
->toContain('dark:hover:bg-white/[0.07]');
expect($resourceCard)
->toContain('class="icon-button shrink-0"')
->toContain('title="Edit domains"');
});
it('exposes enable and disable public access methods on service index', function () {
$source = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
expect($source)
->toContain('public function enablePublicAccess(): void')
->toContain('public function disablePublicAccess(): void')
->toContain('$this->instantSave()');
});
+59
View File
@@ -0,0 +1,59 @@
<?php
it('renders the dashed empty-state card with title description and icon', function () {
$html = $this->blade(
'<x-empty title="No tags yet" description="Add a tag to group deployments." icon-name="tags" />'
);
$html->assertSee('No tags yet')
->assertSee('Add a tag to group deployments.')
->assertSee('empty-state', false)
->assertSee('border-dashed', false)
->assertSee('min-h-80', false);
});
it('renders empty states flush with no outer inset inside settings sections', function () {
$html = $this->blade(
<<<'BLADE'
<x-application.settings-section title="Scheduled tasks" flush>
<x-empty title="No scheduled tasks"
description="Create a task to run maintenance commands."
icon-name="browser-terminal" />
</x-application.settings-section>
BLADE
);
$html->assertSee('is-flush', false)
->assertSee('empty-state', false)
->assertSee('No scheduled tasks');
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.application-settings-section-body.is-flush .empty-state')
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
->toContain('.application-settings-section > .empty-state')
->toMatch('/\.application-settings-section-body\.is-flush \.empty-state[\s\S]*?margin:\s*0/');
});
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" />'
);
$html->assertSee('No cloud tokens')
->assertSee('min-h-44', false)
->assertDontSee('min-h-80', false);
});
it('renders action content from both contents and actions slots', function () {
$contents = $this->blade(
'<x-empty title="Empty" icon-name="keys"><x-slot:contents><button type="button">From contents</button></x-slot:contents></x-empty>'
);
$contents->assertSee('From contents');
$actions = $this->blade(
'<x-empty title="Empty" icon-name="keys"><x-slot:actions><button type="button">From actions</button></x-slot:actions></x-empty>'
);
$actions->assertSee('From actions');
});
+23
View File
@@ -0,0 +1,23 @@
<?php
test('environment delete button label is simply Delete', function () {
$view = file_get_contents(resource_path('views/livewire/project/delete-environment.blade.php'));
expect($view)
->toContain('buttonTitle="Delete"')
->not->toContain('buttonTitle="Delete Environment"');
});
test('environment delete section stacks on small screens', function () {
$view = file_get_contents(resource_path('views/livewire/project/environment-edit.blade.php'));
expect($view)
->toContain('flex flex-col gap-4 px-5 py-4 sm:flex-row sm:items-start sm:justify-between')
->toContain('livewire:project.delete-environment');
});
test('app-tab utility includes icon text spacing', function () {
$css = file_get_contents(resource_path('css/utilities.css'));
expect($css)->toMatch('/@utility app-tab \{[^}]*gap-1/s');
});
@@ -183,7 +183,6 @@ test('convertContainerEnvsToArray', function () {
"FEATURE_FLAG_WITH_EQUALS=true=123",
"REGISTRY_URL=docker.io",
"SUBSCRIPTION_PROVIDER=stripe",
"TELESCOPE_ENABLED=false",
"POSTGRES_HOST_AUTH_METHOD=trust",
"DB_PASSWORD=password",
"SSH_MUX_ENABLED=true",
@@ -192,7 +191,6 @@ test('convertContainerEnvsToArray', function () {
"DB_HOST=host.docker.internal",
"POSTGRES_DB=coolify",
"APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB9mIxcl+k=",
"DEBUGBAR_ENABLED=false",
"APP_ID=development",
"DB_DATABASE=coolify",
"DUSK_DRIVER_URL=http://selenium:4444",
@@ -0,0 +1,123 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('does not load environment variables during mount', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('readyToLoad'))->toBeFalse()
->and($component->instance()->environmentVariables)->toHaveCount(0)
->and($component->get('variables'))->toBeNull();
});
it('loads environment variables when loadEnvironmentVariables is called', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->assertSee('Loading environment variables...')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
->assertDontSee('Loading environment variables...')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toContain('API_KEY')
->toContain('DATABASE_URL');
});
it('builds developer view text only after switching to developer view', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables');
expect($component->get('variables'))->toBeNull();
$component->call('switch')->assertSet('view', 'dev');
expect($component->get('variables'))->toContain('API_KEY=secret');
});
it('is idempotent when loadEnvironmentVariables is called twice', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true);
expect($component->instance()->environmentVariables->pluck('key')->all())
->toContain('API_KEY');
});
@@ -7,7 +7,7 @@ it('uses Alpine entangle to switch add value field immediately when multiline is
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
->toContain('<template x-if="isMultiline">')
->toContain('<template x-if="!isMultiline">')
->toContain('x-model="isMultiline"')
->toContain('id="is_multiline"')
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
->toContain('wire:key="env-value-textarea"')
->toContain('wire:key="env-value-input"');
@@ -21,20 +21,22 @@ it('uses distinct keyed branches for the edit value field modes', function () {
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('lazy-loads decrypted values only when opening the edit modal', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:click="loadValues"')
->toContain('wireOpen="editorOpen"')
->toContain('$valuesLoaded')
->toContain('Loading value...');
});
it('keeps the environment variable delete button compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)->not->toContain('buttonFullWidth="true"');
});
it('aligns environment variable settings and actions in a responsive row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"')
->toContain('class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
@@ -44,27 +46,27 @@ it('uses sans font for the developer bulk environment variable editor', function
->not->toContain('wire:model="variablesPreview" monospace');
});
it('renders the environment variable search field above the production title', function () {
it('loads environment variables asynchronously after first paint', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect(strpos($view, 'aria-label="Search environment variables"'))
->toBeLessThan(strpos($view, '<h3>Production Environment Variables</h3>'));
expect($view)
->toContain('wire:init="loadEnvironmentVariables"')
->toContain('Loading environment variables...');
});
it('renders a single no results message for empty environment variable searches', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)')
->toContain('<div>No environment variables found.</div>')
->toContain('@else');
->toContain('@if ($this->isSearchActive && $totalRows === 0)')
->toContain('No environment variables found');
});
it('only renders the production section when production variables are visible', function () {
it('paginates a unified environment variable table instead of loading every row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())')
->not->toContain('@forelse ($this->environmentVariables as $env)')
->not->toContain('@empty');
->toContain('$this->environmentVariablePageRows')
->toContain('nextEnvironmentVariablePage')
->not->toContain('@forelse ($this->environmentVariables as $env)');
});
@@ -0,0 +1,244 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('paginates managed environment variables without loading every row into the page collection', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
// Remove factory defaults so counts stay predictable.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 25; $i++) {
EnvironmentVariable::create([
'key' => sprintf('VAR_%02d', $i),
'value' => "value-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Creating production vars auto-mirrors preview rows; keep this test production-only.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariableRowCount)->toBe(25)
->and($component->instance()->environmentVariableLastPage)->toBe(3)
->and($component->instance()->environmentVariablePageRows)->toHaveCount(10)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'VAR_01', 'VAR_02', 'VAR_03', 'VAR_04', 'VAR_05',
'VAR_06', 'VAR_07', 'VAR_08', 'VAR_09', 'VAR_10',
]);
$component->call('nextEnvironmentVariablePage');
expect($component->get('page'))->toBe(2)
->and($component->instance()->environmentVariablePageRows)->toHaveCount(10)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'VAR_11', 'VAR_12', 'VAR_13', 'VAR_14', 'VAR_15',
'VAR_16', 'VAR_17', 'VAR_18', 'VAR_19', 'VAR_20',
]);
$component->call('setEnvironmentVariablePage', 3);
expect($component->instance()->environmentVariablePageRows)->toHaveCount(5)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe(['VAR_21', 'VAR_22', 'VAR_23', 'VAR_24', 'VAR_25']);
});
it('paginates across production then preview segments', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 8; $i++) {
EnvironmentVariable::create([
'key' => sprintf('PROD_%02d', $i),
'value' => "prod-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Creating production vars also auto-creates preview mirrors; replace with controlled preview set.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
for ($i = 1; $i <= 8; $i++) {
EnvironmentVariable::create([
'key' => sprintf('PREV_%02d', $i),
'value' => "prev-{$i}",
'order' => $i,
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariableRowCount)->toBe(16)
->and($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'PROD_01', 'PROD_02', 'PROD_03', 'PROD_04', 'PROD_05',
'PROD_06', 'PROD_07', 'PROD_08', 'PREV_01', 'PREV_02',
]);
$component->call('nextEnvironmentVariablePage');
expect($component->instance()->environmentVariablePageRows->pluck('environmentVariable.key')->all())
->toBe([
'PREV_03', 'PREV_04', 'PREV_05', 'PREV_06', 'PREV_07', 'PREV_08',
]);
});
it('counts with a lightweight query rather than hydrating every model for the page total', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
for ($i = 1; $i <= 15; $i++) {
EnvironmentVariable::create([
'key' => sprintf('COUNT_%02d', $i),
'value' => "value-{$i}",
'order' => $i,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
// Drop auto-created preview mirrors so only production rows exist.
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
DB::enableQueryLog();
$count = $component->instance()->environmentVariableRowCount;
$pageRows = $component->instance()->environmentVariablePageRows;
$queries = collect(DB::getQueryLog());
expect($count)->toBe(15)
->and($pageRows)->toHaveCount(10);
$selectQueries = $queries->filter(fn (array $query) => str_contains(strtolower($query['query']), 'select'));
// One COUNT for the production segment + one SELECT for the page slice.
expect($selectQueries->count())->toBeLessThanOrEqual(4);
});
it('does not decrypt environment variable values until edit is opened', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->delete();
$env = EnvironmentVariable::create([
'key' => 'SECRET_KEY',
'value' => 'super-secret-value',
'order' => 1,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->where('is_preview', true)
->delete();
$component = Livewire::test(All::class, ['resource' => $application->fresh()])
->call('loadEnvironmentVariables');
$pageEnv = $component->instance()->environmentVariablePageRows->first()['environmentVariable'];
expect(array_key_exists('value', $pageEnv->getAttributes()))->toBeFalse()
->and($pageEnv->getAppends())->toBe([]);
$show = Livewire::test(Show::class, [
'env' => $pageEnv,
'type' => 'application',
]);
expect($show->get('valuesLoaded'))->toBeFalse()
->and($show->get('value'))->toBeNull()
->and($show->get('editorOpen'))->toBeFalse();
$show->set('editorOpen', true)->call('loadValues');
expect($show->get('valuesLoaded'))->toBeTrue()
->and($show->get('value'))->toBe('super-secret-value')
->and($show->get('editorOpen'))->toBeTrue();
});
@@ -50,12 +50,61 @@ it('filters production environment variables by key case-insensitively', functio
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
it('orders environment variables by creation order or alphabetically based on the setting', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'ZEBRA',
'value' => 'last-key',
'order' => 1,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'ALPHA',
'value' => 'first-key',
'order' => 2,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->assertSet('is_env_sorting_enabled', false);
$userManagedKeys = $component->instance()->environmentVariables
->pluck('key')
->filter(fn (string $key) => in_array($key, ['ZEBRA', 'ALPHA'], true))
->values()
->all();
expect($userManagedKeys)->toBe(['ZEBRA', 'ALPHA']);
$component
->set('is_env_sorting_enabled', true)
->call('instantSave');
$alphabeticallyOrderedKeys = $component->instance()->environmentVariables
->pluck('key')
->filter(fn (string $key) => in_array($key, ['ZEBRA', 'ALPHA'], true))
->values()
->all();
expect($alphabeticallyOrderedKeys)
->toBe(['ALPHA', 'ZEBRA'])
->and($application->settings->fresh()->is_env_sorting_enabled)->toBeTrue();
});
it('treats production environment variable search underscore wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -76,6 +125,7 @@ it('treats production environment variable search underscore wildcards literally
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
@@ -104,6 +154,7 @@ it('filters preview environment variables by key case-insensitively', function (
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'token');
expect($component->instance()->environmentVariablesPreview->pluck('key')->all())
@@ -124,6 +175,7 @@ YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
@@ -150,12 +202,13 @@ it('searches service environment variables without requiring preview variables',
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('Preview Deployments Environment Variables');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
->toBe(['API_KEY'])
->and($component->instance()->showPreview)->toBeFalse();
});
it('does not show the empty production message when search only matches hardcoded variables', function () {
@@ -172,9 +225,9 @@ YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('No environment variables found.');
->assertDontSee('No environment variables found');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
@@ -200,6 +253,7 @@ it('keeps developer view unfiltered after searching', function () {
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->call('switch')
->assertSet('view', 'dev');
@@ -229,6 +283,7 @@ it('does not delete non-matching variables when saving developer view after sear
]);
Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->call('switch')
->call('submit');
@@ -261,11 +316,11 @@ it('hides the preview section when search filters out all preview variables', fu
]);
$component = Livewire::test(All::class, ['resource' => $application])
->call('loadEnvironmentVariables')
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
->toBe(['API_KEY'])
->and($component->instance()->environmentVariablesPreview)->toHaveCount(0);
});
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
});
it('renders redesigned error pages with shared shell and actions', function (string $view, string $code, string $title) {
$exception = new HttpException((int) $code, 'Test error message');
$html = view($view, ['exception' => $exception])->render();
expect($html)
->toContain('error-shell')
->toContain('error-code')
->toContain('error-title')
->toContain('error-actions')
->toContain($code)
->toContain($title)
->toContain('Contact support')
->toContain(config('constants.urls.contact'));
})->with([
'400' => ['errors.400', '400', 'Bad request'],
'401' => ['errors.401', '401', 'You shall not pass!'],
'402' => ['errors.402', '402', 'Payment required'],
'403' => ['errors.403', '403', 'You shall not pass!'],
'404' => ['errors.404', '404', 'How did you get here?'],
'419' => ['errors.419', '419', 'This page is definitely old, not like you!'],
'429' => ['errors.429', '429', 'Woah, slow down there!'],
'500' => ['errors.500', '500', 'Wait, this is not cool...'],
'503' => ['errors.503', '503', 'We are working on serious things.'],
]);
it('includes go back and dashboard actions on standard error pages', function () {
$exception = new HttpException(404, 'Not found');
$html = view('errors.404', ['exception' => $exception])->render();
expect($html)
->toContain('Go back')
->toContain('Dashboard')
->toContain(route('dashboard'));
});
it('uses login as primary action on session expired page', function () {
$exception = new HttpException(419, 'Page expired');
$html = view('errors.419', ['exception' => $exception])->render();
expect($html)
->toContain('/login')
->toContain('Back to login')
->toContain('Using a reverse proxy or Cloudflare Tunnel?')
->not->toContain('>Dashboard</');
});
it('shows purified exception message on 500 page', function () {
$exception = new RuntimeException('Database connection failed');
$html = view('errors.500', ['exception' => $exception])->render();
expect($html)
->toContain('error-code-danger')
->toContain('error-message')
->toContain('Database connection failed');
});
+69
View File
@@ -0,0 +1,69 @@
<?php
use Illuminate\Support\Facades\Blade;
it('disables the button and targets the wire click action while loading', function () {
$html = Blade::render(
'<x-forms.button wire:click.prevent="checkConnection"><x-reicon name="refresh" class="size-3.5" />Check connection</x-forms.button>'
);
expect($html)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.class="is-loading"')
->toContain('wire:target="checkConnection"')
->toContain('wire:loading')
->toContain('animate-spin')
->toContain('Check connection');
});
it('uses an explicit wire target for submit buttons without wire click', function () {
$html = Blade::render(
'<x-forms.button type="submit" wire:target="addToken">Validate and add</x-forms.button>'
);
expect($html)
->toContain('type="submit"')
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.class="is-loading"')
->toContain('wire:target="addToken"')
->toContain('animate-spin')
->toContain('Validate and add');
});
it('strips method arguments when deriving the loading target', function () {
$html = Blade::render(
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
);
expect($html)
->toContain('wire:target="setPrivateKey"')
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.class="is-loading"');
});
it('skips loading attributes when the loading indicator is disabled', function () {
$html = Blade::render(
'<x-forms.button wire:click="checkConnection" :showLoadingIndicator="false">Check connection</x-forms.button>'
);
expect($html)
->not->toContain('wire:loading.attr="disabled"')
->not->toContain('wire:loading.class="is-loading"')
->not->toContain('animate-spin')
->toContain('Check connection');
});
it('hides static button icons with the shared is-loading utility', function () {
$css = file_get_contents(resource_path('css/utilities.css'));
expect($css)->toContain('.button.is-loading > svg:not(.animate-spin)');
});
it('applies automatic loading behavior on the server private key check connection button', function () {
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
expect($view)
->toContain('wire:click.prevent="checkConnection"')
->toContain('<x-reicon name="refresh" class="size-3.5" />')
->toContain('<x-forms.button canGate="update" :canResource="$server"');
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Source\Github\Change;
use App\Models\GithubApp;
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 () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
});
it('places connection status next to the title and delete in the danger zone view', function () {
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($view)
->toContain('<x-slot:titleMeta>')
->toContain('label="Connected"')
->toContain('source.github.danger')
->toContain('Danger Zone')
->toContain('github-app-danger-section')
->toContain('submitAction="delete"');
// Connection status lives in titleMeta; the dashboard navbar has no actions slot.
$navbarStart = strpos($view, '<x-dashboard.navbar');
$navbarEnd = strpos($view, '</x-dashboard.navbar>');
expect($navbarStart)->not->toBeFalse();
expect($navbarEnd)->not->toBeFalse();
$navbarBlock = substr($view, $navbarStart, $navbarEnd - $navbarStart);
expect($navbarBlock)
->toContain('<x-slot:titleMeta>')
->not->toContain('<x-slot:actions>');
expect($navbar)
->toContain('@isset($titleMeta)')
->toContain("request()->routeIs('source.github.show', 'source.github.danger')");
expect(file_get_contents(base_path('routes/web.php')))
->toContain("->name('source.github.danger')");
});
it('sets the danger active tab from the danger route', function () {
$githubApp = GithubApp::create([
'uuid' => (string) str()->uuid(),
'name' => 'Layout Test App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'client',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
'is_system_wide' => false,
]);
$this->get(route('source.github.danger', ['github_app_uuid' => $githubApp->uuid]))
->assertOk()
->assertSee('Danger zone', false)
->assertSee('Delete GitHub App', false)
->assertSee('Delete', false);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSet('activeTab', 'general');
});
@@ -0,0 +1,17 @@
<?php
/**
* Header version badge should open the matching GitHub release page.
*/
test('desktop header version links to the coolify github release for the installed version', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$version = file_get_contents(resource_path('views/components/version.blade.php'));
expect($layout)
->toContain('<x-version')
->not->toContain("class=\"text-[10.5px] font-medium text-neutral-400 dark:text-fg-faint\">v{{ config('constants.coolify.version') }}</span>");
expect($version)
->toContain("https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}")
->toContain('target="_blank"');
});
+63
View File
@@ -0,0 +1,63 @@
<?php
use Illuminate\Support\Facades\Blade;
test('helper trigger is a button that stops label activation', function () {
$path = resource_path('views/components/helper.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('type="button"')
->toContain('@click.prevent.stop')
->toContain('aria-label="More information"')
->toContain('info-helper-popup')
->toContain('name="info-circle"')
->not->toContain('<div x-ref="trigger" class="info-helper"');
});
test('helper popup uses the redesigned raised surface styles', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($helper)
->toContain('info-helper-popup')
->toContain('x-transition:enter')
->toContain('text-[13px] leading-5');
expect($utilities)
->toContain('@utility info-helper-popup')
->toContain('dark:bg-raised')
->toContain('dark:border-white/10')
->toContain('shadow-modal')
->toContain('@utility auth-tooltip')
->toContain('rounded-lg');
});
test('listbox keeps the helper outside the label association', function () {
$path = resource_path('views/components/forms/listbox.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('Keep helper outside the label')
->toContain('<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">')
->toContain('class="mb-0! flex items-center gap-1.5 leading-4"')
->toMatch('/<label for="\{\{ \$id \}\}-trigger"[^>]*>[\s\S]*?<\/label>\s*@if \(\$helper\)/');
});
test('helper popup renders when the component is used next to a listbox label', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="serverTimezone" label="Server timezone"
helper="Used for backup schedules." :options="[
['value' => 'UTC', 'label' => 'UTC'],
]" :wire="false" value="UTC" />
BLADE);
expect($html)
->toContain('Server timezone')
->toContain('Used for backup schedules.')
->toContain('type="button"')
->toContain('aria-label="More information"')
->toContain('serverTimezone-trigger')
// Helper button must not be nested inside the label[for=trigger] element.
->not->toMatch('/<label[^>]*for="serverTimezone-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/');
});
@@ -0,0 +1,45 @@
<?php
/**
* Programmatic confirmation modals must not leave empty layout rows.
*/
test('modal confirmation roots are inline so hidden triggers do not stretch full width', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
expect($modal)
->toContain("'relative h-auto max-w-full'")
->toContain("'inline-flex w-auto' => ! \$buttonFullWidth")
->toContain("'flex w-full' => \$buttonFullWidth")
->not->toContain("'relative h-auto'");
});
test('server mobile proxy confirmations sit inside a display-none host', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
expect($navbar)
->toContain('server-mobile-restart-proxy-trigger')
->toContain('server-mobile-stop-proxy-trigger')
->toMatch('/class="hidden"[^>]*>\s*<x-modal-confirmation title="Confirm Proxy Restart/');
});
test('application database and service programmatic modals are layout-hidden', function () {
foreach ([
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
] as $path) {
expect(file_get_contents($path))
->toContain('class="hidden" aria-hidden="true"')
->toContain('<x-slot:trigger>');
}
});
test('slide-over event shells use display contents', function () {
expect(file_get_contents(resource_path('views/components/slide-over.blade.php')))
->toContain("'class' => 'contents'");
});
test('process dialog event shells use display contents', function () {
expect(file_get_contents(resource_path('views/components/process-dialog.blade.php')))
->toContain("'class' => 'contents'");
});
@@ -0,0 +1,16 @@
<?php
test('app css hides native webkit search cancel button to avoid double clear controls', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('input[type="search"]::-webkit-search-cancel-button')
->toContain('display: none');
});
test('resource index keeps a single custom clear search control', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect(substr_count($view, 'aria-label="Clear search"'))->toBe(1)
->and($view)->toContain('type="search"');
});
@@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Blade;
it('registers the upload reicon used by import backup navigation', function () {
$path = resource_path('views/components/reicon.blade.php');
$contents = file_get_contents($path);
expect($contents)->toContain("'upload' => ");
$html = Blade::render('<x-reicon name="upload" class="menu-item-icon" />');
expect($html)
->toContain('viewBox="0 0 24 24"')
->toContain('fill="currentColor"')
->toContain('menu-item-icon')
->not->toBe('<svg class="menu-item-icon size-4" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
</svg>');
});
it('uses the upload reicon for import backup in database configuration nav', function () {
$contents = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
expect($contents)
->toContain("'label' => 'Import Backup'")
->toContain("'icon' => 'upload'");
});
it('uses the upload reicon for import backup in service database sidebar', function () {
$contents = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php'));
expect($contents)
->toContain("'label' => 'Import Backup'")
->toContain("'icon' => 'upload'")
->not->toMatch("/'label' => 'Import Backup',\s*'route' => 'project\.service\.database\.import',\s*'icon' => 'storages'/s");
});
@@ -65,6 +65,21 @@ it('versions.json contains traefik branches with patch versions', function () {
expect($branch)->toMatch('/^v\d+\.\d+$/'); // e.g., "v3.6"
expect($version)->toMatch('/^\d+\.\d+\.\d+$/'); // e.g., "3.6.0"
}
// Track current supported branches so the checker can surface security patches
// (see https://github.com/coollabsio/coolify/issues/11025)
expect($traefikVersions)->toHaveKeys(['v3.7', 'v3.6', 'v2.11']);
expect(version_compare($traefikVersions['v3.7'], '3.7.8', '>='))->toBeTrue();
expect(version_compare($traefikVersions['v3.6'], '3.6.23', '>='))->toBeTrue();
expect(version_compare($traefikVersions['v2.11'], '2.11.52', '>='))->toBeTrue();
// Newest tracked branch should sort as the highest major.minor
$newestBranch = collect($traefikVersions)
->keys()
->map(fn (string $branch) => ltrim($branch, 'v'))
->sort(SORT_NATURAL)
->last();
expect($newestBranch)->toBe('3.7');
});
it('formats version with v prefix for display', function () {
@@ -0,0 +1,65 @@
<?php
use Illuminate\Support\Facades\Blade;
test('listbox trigger styles constrain width and ellipsize long labels', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.listbox-trigger {')
->toContain('min-width: 0;')
->toContain('overflow: hidden;')
->toContain('.listbox-trigger-label {')
->toContain('text-overflow: ellipsis;')
->toContain('white-space: nowrap;');
});
test('listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="longOption" label="Example"
:options="[
['value' => 'long', 'label' => 'A very long option label that should truncate'],
]" :wire="false" value="long" />
BLADE);
expect($html)
->toContain('class="w-full min-w-0"')
->toContain('relative min-w-0')
->toContain('listbox-trigger')
->toContain('listbox-trigger-label')
->toContain(':title="current"')
->not->toContain('class="truncate" x-text="current"');
});
test('searchable listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="tz" label="Timezone"
:options="[['value' => 'UTC', 'label' => 'UTC']]" :wire="false" value="UTC" />
BLADE);
expect($html)
->toContain('class="w-full min-w-0"')
->toContain('relative min-w-0')
->toContain('listbox-trigger-label')
->toContain(':title="current"');
});
test('notification event multiselect truncates long selected summaries', function () {
$html = Blade::render(<<<'BLADE'
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
['property' => 'dockerCleanupFailureSlackNotifications', 'label' => 'Docker cleanup failure', 'enabled' => true],
['property' => 'serverDiskUsageSlackNotifications', 'label' => 'Disk usage warning', 'enabled' => true],
['property' => 'serverUnreachableSlackNotifications', 'label' => 'Server unreachable', 'enabled' => true],
['property' => 'serverPatchSlackNotifications', 'label' => 'Server patching', 'enabled' => true],
]" />
BLADE);
expect($html)
->toContain('class="w-full min-w-0"')
->toContain('relative min-w-0')
->toContain('listbox-trigger')
->toContain('listbox-trigger-label')
->toContain('Docker cleanup failure, Disk usage warning, Server unreachable, Server patching')
->toContain('title="Docker cleanup failure, Disk usage warning, Server unreachable, Server patching"')
->toContain('4/4');
});
@@ -37,8 +37,26 @@ it('refreshes configuration changes after health check saves', function (string
it('refreshes configuration changes after environment variable settings are saved', function () {
Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application])
->call('loadEnvironmentVariables')
->set('use_build_secrets', true)
->call('instantSave')
->assertHasNoErrors()
->assertDispatched('configurationChanged');
});
it('persists environment variable order as an application setting', function () {
expect($this->application->settings->is_env_sorting_enabled)->toBeFalse();
Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application])
->assertSee('Environment variable order')
->assertSee('Build secrets')
->assertDontSeeHtml('>Sort</button>')
->call('loadEnvironmentVariables')
->set('is_env_sorting_enabled', true)
->call('instantSave')
->assertHasNoErrors()
->assertDispatched('configurationChanged')
->assertSet('is_env_sorting_enabled', true);
expect($this->application->settings->fresh()->is_env_sorting_enabled)->toBeTrue();
});
+6 -7
View File
@@ -13,7 +13,7 @@ it('registers geist mono from a local asset for log surfaces', function () {
->toContain("url('../fonts/geist-sans-variable.woff2')")
->and($appCss)
->toContain("--font-sans: 'Geist Sans', Inter, sans-serif")
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;')
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-clip;')
->toContain("--font-logs: 'Geist Mono'")
->toContain("--font-geist-sans: 'Geist Sans'")
->and($fontPath)
@@ -30,16 +30,15 @@ it('uses geist mono for shared logs and terminal rendering', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($sharedLogsView)
->toContain('class="font-logs max-w-full cursor-default"')
->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"')
->toContain('font-logs max-w-full cursor-default')
->toContain('font-logs max-w-full whitespace-pre-wrap break-all text-neutral-400')
->and($deploymentLogsView)
->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"')
->toContain('flex min-w-0 flex-col font-logs')
->toContain('mb-2 font-logs text-neutral-400')
->and($activityMonitorView)
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"')
->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->toContain('font-mono')
->and($terminalClient)
->toContain('"Geist Mono"');
});
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Auth shell headers should brand the product so users know they are on Coolify.
*/
test('login register and forgot password headers are Coolify', function () {
$login = file_get_contents(resource_path('views/auth/login.blade.php'));
$register = file_get_contents(resource_path('views/auth/register.blade.php'));
$forgot = file_get_contents(resource_path('views/auth/forgot-password.blade.php'));
expect($login)
->toContain('title="Coolify"')
->not->toContain('title="Welcome back"');
expect($register)
->toContain('title="Coolify"')
->not->toContain(":title=\"\$isFirstUser ? 'Create the root account' : 'Create your account'\"");
expect($forgot)
->toContain('title="Coolify"')
->not->toContain("title=\"{{ __('auth.forgot_password_heading') }}\"");
});
test('confirm password page uses the shared auth shell', function () {
$confirm = file_get_contents(resource_path('views/auth/confirm-password.blade.php'));
expect($confirm)
->toContain('x-auth.shell')
->toContain('title="Coolify"')
->toContain('x-auth.alert')
->toContain('auth-guidance')
->not->toContain('bg-gray-50 dark:bg-base')
->not->toContain('!text-5xl font-extrabold');
});
+14 -4
View File
@@ -19,9 +19,12 @@ it('uses native mobile menus for databases and services', function () {
->toContain("value.startsWith('location|')")
->toContain('window.location.href = url')
->toContain('application-mobile-stop-trigger')
->toContain('application-mobile-deploy-trigger')
->toContain('application-mobile-restart-trigger')
->toContain('application-mobile-force-deploy-trigger')
->toContain('wire:click="deploy"')
->toContain('wire:click="force_deploy_without_cache"')
->not->toContain('application-mobile-deploy-trigger')
->not->toContain('application-mobile-force-deploy-trigger')
->not->toContain('Confirm Application Deployment?')
->not->toContain('<optgroup label="Actions">');
expect($applicationMobileActions)
@@ -51,7 +54,9 @@ it('uses native mobile menus for databases and services', function () {
->toContain('x-model="selected"')
->toContain('database-restart-trigger')
->toContain('database-stop-trigger')
->toContain('database-start-trigger')
->toContain("\$wire.dispatch('startEvent')")
->not->toContain('database-start-trigger')
->not->toContain('Confirm Database Start?')
->toContain('scrollbar hidden min-h-10')
->not->toContain('<optgroup label="Links">')
->not->toContain('<optgroup label="Actions">')
@@ -86,8 +91,13 @@ it('uses native mobile menus for databases and services', function () {
->toContain('x-model="selected"')
->toContain('service-restart-trigger')
->toContain('service-stop-trigger')
->toContain('service-forceDeploy-trigger')
->toContain("\$wire.dispatch('startEvent')")
->toContain("\$wire.dispatch('forceDeployEvent')")
->toContain('service-pullAndRestart-trigger')
->not->toContain('service-start-trigger')
->not->toContain('service-forceDeploy-trigger')
->not->toContain('Confirm Service Deployment?')
->not->toContain('Confirm Service Force Deployment?')
->toContain('scrollbar hidden min-h-10')
->toContain('mb-4 w-full md:mb-0 md:hidden')
->toContain('hidden flex-wrap items-center gap-2 md:flex')
@@ -0,0 +1,21 @@
<?php
/**
* Destructive confirmation action lists must use reicon trash glyphs,
* not dismiss/close X icons or raw stroke SVGs.
*/
test('modal confirmation action list uses trash reicon markers', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
expect($modal)
->toContain('<x-reicon name="trash" class="mt-0.5 size-3.5 shrink-0" />')
->not->toContain('d="M6 18L18 6M6 6l12 12"');
// Action bullets use trash; header dismiss may still use close X.
$actionSection = Str::after($modal, 'The following actions will be performed:');
$actionSection = Str::before($actionSection, '@if (!$disableTwoStepConfirmation)');
expect($actionSection)
->toContain('name="trash"')
->not->toContain('name="x"');
});
@@ -0,0 +1,15 @@
<?php
/**
* Step 1 (checkbox options) should only show Continue — dismiss is the header X.
*/
test('modal confirmation step 1 does not render a cancel button', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
$step1 = Str::after($modal, '<!-- Step 1: Select actions -->');
$step1 = Str::before($step1, '<!-- Step 2: Confirm deletion -->');
expect($step1)
->toContain('step1ButtonText')
->not->toContain('Cancel');
});
@@ -59,4 +59,14 @@ it('authorizes every volume backup form control', function (string $path, array
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
],
],
'service domains controls' => [
'resources/views/livewire/project/service/domains.blade.php',
[
'/<x-forms\.select(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*id="newServiceApplicationId")[^>]*>/s',
'/<x-forms\.input(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*id="newDomain")[^>]*>/s',
'/<x-forms\.button(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*wire:click="generateDomain")[^>]*>/s',
'/<x-forms\.button(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*type="submit")[^>]*>/s',
'/wire:click="checkAllDns"/s',
],
],
]);
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* Family H1s hide when the desktop shell starts (lg+ fixed tabs + sidebar).
* Collection indexes always keep their title; resource in-flow titles only
* show below md (when the fixed resource tab bar is hidden).
*/
test('dashboard navbar hides family titles at lg to match the desktop shell', function () {
expect(file_get_contents(resource_path('views/components/dashboard/navbar.blade.php')))
->toContain("'titleOnDesktop' => false")
->toContain("'lg:hidden' => ! \$titleOnDesktop")
->toContain('lg:h-12')
->not->toContain('lg:h-10');
});
test('fixed layer-2 spacers match the fixed bar height', function () {
$paths = [
resource_path('views/components/dashboard/navbar.blade.php'),
resource_path('views/components/project/navbar.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
];
foreach ($paths as $path) {
$blade = file_get_contents($path);
expect($blade)->toContain('lg:h-12');
expect($blade)->not->toContain('lg:h-10');
expect($blade)->not->toContain('lg:block lg:h-5');
}
});
test('resource in-flow titles show until the desktop topbar takes over at lg', function () {
$paths = [
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
];
foreach ($paths as $path) {
$blade = file_get_contents($path);
expect($blade)
->toContain('w-full lg:hidden')
->toContain('w-full md:hidden')
->not->toContain('w-full xl:hidden');
}
});
test('slide-over shells do not reserve layout space', function () {
expect(file_get_contents(resource_path('views/components/slide-over.blade.php')))
->toContain("'class' => 'contents'")
->not->toContain('relative w-auto h-auto');
});
test('collection index headers stack so title and actions never overlap', function () {
$paths = [
resource_path('views/livewire/server/index.blade.php'),
resource_path('views/livewire/project/index.blade.php'),
resource_path('views/source/all.blade.php'),
resource_path('views/livewire/destination/index.blade.php'),
resource_path('views/livewire/storage/index.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('flex-col gap-3 sm:flex-row')
->toContain('text-[24px]!');
}
});
@@ -18,6 +18,12 @@ it('renders password input with Alpine-managed visibility state', function () {
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"')
->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"")
->toContain('input-with-password-toggle')
->toContain('password-toggle')
->toContain('z-10')
// Visible state uses reicon eye-off2 (distinct path start).
->toContain("x-show=\"type === 'text'\"")
->toContain('M2.53033 1.46967')
->not->toContain('changePasswordFieldType');
});
@@ -27,6 +33,24 @@ it('renders password input before visibility toggle in tab order', function () {
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
it('does not add toggle clearance when allowToPeak is disabled', function () {
$html = Blade::render('<x-forms.input type="password" id="secret" :allow-to-peak="false" />');
expect($html)
->not->toContain('input-with-password-toggle')
->not->toContain('aria-label="Toggle password visibility"');
});
it('keeps password toggle padding above settings-workspace input overrides', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.input.input-with-password-toggle')
->toContain('.application-settings-workspace .input.input-with-password-toggle')
->toContain('.application-settings-form .input.input-with-password-toggle')
->toContain('padding-right: 2.5rem');
});
it('renders password textarea with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
@@ -63,7 +87,24 @@ it('resets password visibility on success event for env-var-input', function ()
expect($html)
->toContain("@success.window=\"type = 'password'\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"');
->toContain('x-bind:type="type"')
->toContain('input-with-password-toggle')
->toContain('password-toggle')
->toContain('M2.53033 1.46967');
});
it('registers the eye-off2 reicon used when password value is visible', function () {
$path = resource_path('views/components/reicon.blade.php');
$contents = file_get_contents($path);
expect($contents)->toContain("'eye-off2' => ");
$html = Blade::render('<x-reicon name="eye-off2" class="size-[18px]" />');
expect($html)
->toContain('viewBox="0 0 24 24"')
->toContain('fill="currentColor"')
->toContain('M2.53033 1.46967');
});
it('renders env var password input before visibility toggle in tab order', function () {
@@ -0,0 +1,69 @@
<?php
/**
* Security / notifications family: mobile-only H1 on list views; actions in layer-2 nav.
* Detail views keep the resource title on desktop.
*/
test('security navbar defaults to hiding the title on desktop', function () {
$securityNavbar = file_get_contents(resource_path('views/components/security/navbar.blade.php'));
$notificationNavbar = file_get_contents(resource_path('views/components/notification/navbar.blade.php'));
expect($securityNavbar)
->toContain("'titleOnDesktop' => false")
->toContain(':titleOnDesktop="$titleOnDesktop"');
expect($notificationNavbar)
->toContain("'titleOnDesktop' => false")
->toContain(':titleOnDesktop="$titleOnDesktop"');
});
test('security list views place create actions next to the tabs not in titleActions', function () {
$pages = [
resource_path('views/livewire/security/private-key/index.blade.php') => [
'New private key',
'New key',
'Delete unused keys',
'Delete unused',
],
resource_path('views/livewire/security/cloud-tokens.blade.php') => [
'New token',
],
resource_path('views/livewire/security/cloud-init-scripts.blade.php') => [
'New script',
],
];
foreach ($pages as $path => $labels) {
$blade = file_get_contents($path);
expect($blade)
->toContain('<x-slot:actions>')
->not->toContain('<x-slot:titleActions>');
foreach ($labels as $label) {
expect($blade)->toContain($label);
}
}
});
test('layer-2 navbar stacks tabs and actions on small screens', function () {
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($navbar)
->toContain('flex-col gap-2 sm:flex-row')
->toContain('[&_.button]:whitespace-nowrap');
});
test('security detail views keep the resource title on desktop', function () {
$shows = [
resource_path('views/livewire/security/private-key/show.blade.php'),
resource_path('views/livewire/security/cloud-provider-token/show.blade.php'),
resource_path('views/livewire/security/cloud-init-script/show.blade.php'),
];
foreach ($shows as $path) {
expect(file_get_contents($path))
->toContain(':titleOnDesktop="true"')
->toContain('<x-slot:actions>');
}
});
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* Centered process dialog used for startup / activity log streams.
*/
test('process dialog shells use display contents so event-only hosts take no layout space', function () {
$component = file_get_contents(resource_path('views/components/process-dialog.blade.php'));
expect($component)
->toContain("'class' => 'contents'")
->toContain('processDialogOpen')
->toContain('role="dialog"')
->toContain('items-center justify-center')
->toContain('min-h-[min(70dvh,28rem)]')
->toContain('sm:min-w-[32rem]')
->not->toContain('translate-x-full');
});
test('service database and proxy startup use the centered process dialog', function () {
$paths = [
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('<x-process-dialog')
->toContain('processDialogOpen = true')
->not->toContain('<x-slide-over @start');
}
});
test('process dialog body styles support a full-height log surface', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.process-dialog')
->toContain('.process-dialog-body')
->toContain('min-height: min(70dvh, 28rem)');
});
@@ -17,27 +17,22 @@ it('adds profile navigation with an appearance tab and route', function () {
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
});
it('moves appearance preferences to the profile appearance view', function () {
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'));
expect($appearanceView)
->toContain('<x-profile.navbar />')
->toContain("setTheme('light')")
->toContain("setTheme('system')")
->toContain("setTheme('dark')")
->toContain("setWidth('center')")
->toContain("setWidth('full')")
->toContain("setZoom('100')")
->toContain("setZoom('90')")
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('aria-label="Use centered width"')
->toContain('aria-label="Use full width"')
->toContain('aria-label="Use 100 percent zoom"')
->toContain('aria-label="Use 90 percent zoom"')
->toContain('max-w-2xl')
->toContain('class="space-y-1.5"')
->toContain('gap-1.5')
->toContain('px-2 py-1 text-sm');
->toContain('Color theme')
->toContain("setTheme('{{ \$option['value'] }}')")
->toContain("['value' => 'light'")
->toContain("['value' => 'system'")
->toContain("['value' => 'dark'")
->not->toContain('Page width')
->not->toContain('Interface density')
->not->toContain('setWidth(')
->not->toContain('setZoom(')
->not->toContain('pageWidth')
->not->toContain("localStorage.getItem('zoom')")
->not->toContain("localStorage.setItem('pageWidth'")
->not->toContain("localStorage.setItem('zoom'");
});
@@ -0,0 +1,75 @@
<?php
use App\Livewire\Project\Show as ProjectShow;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'Test Project',
]);
$this->environment = $this->project->environments()->firstOrFail();
});
test('project show page header links to project settings', function () {
$editUrl = route('project.edit', ['project_uuid' => $this->project->uuid]);
Livewire::test(ProjectShow::class, ['project_uuid' => $this->project->uuid])
->assertSee('Settings')
->assertSee('Project settings', false)
->assertSeeHtml($editUrl);
});
test('environment resources page header links to environment settings', function () {
$editUrl = route('project.environment.edit', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]);
$this->get(route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]))
->assertSuccessful()
->assertSee('Settings')
->assertSee('Environment settings', false)
->assertSee($editUrl, false);
});
test('project show blade includes project settings route', function () {
$view = file_get_contents(resource_path('views/livewire/project/show.blade.php'));
expect($view)
->toContain("route('project.edit'")
->toContain('Project settings')
->toContain('name="settings"');
});
test('environment resource index blade includes environment settings route', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain("route('project.environment.edit'")
->toContain('Environment settings')
->toContain('name="settings"');
});
+35 -29
View File
@@ -1,38 +1,44 @@
<?php
use App\Livewire\Project\Index;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('shows an empty state when there are no projects', function () {
$this->view('livewire.project.index', [
'projects' => collect(),
])
->assertSee('No projects found.')
->assertSee('onboarding');
Livewire::test(Index::class)
->assertSee('No projects yet')
->assertSee('Create a project to organize your environments and resources.')
->assertSee('Open onboarding');
});
it('does not show the empty state when projects exist', function () {
$project = new class
{
public string $name = 'Test Project';
Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'Test Project',
'description' => 'A project description',
]);
public string $description = 'A project description';
public string $uuid = 'test-project-uuid';
public $environments;
public function __construct()
{
$this->environments = collect();
}
public function navigateTo(): string
{
return '#';
}
};
$this->view('livewire.project.index', [
'projects' => collect([$project]),
])
Livewire::test(Index::class)
->assertSee('Test Project')
->assertDontSee('No projects found.');
->assertDontSee('No projects yet');
});
@@ -0,0 +1,136 @@
<?php
use App\Jobs\PullTemplatesFromCDN;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
Cache::flush();
});
it('stores the CDN service templates bundle in shared cache and local file', function () {
$payload = [
'buzz' => [
'category' => 'messaging',
'documentation' => 'https://github.com/block/buzz',
'compose' => '',
'slogan' => 'Buzz',
'tags' => null,
'logo' => 'svgs/buzz.svg',
'minversion' => '0.0.0',
'template_last_updated_at' => '2026-07-23T18:52:29+02:00',
],
'activepieces' => [
'category' => 'automation',
'documentation' => 'https://coolify.io/docs',
'compose' => '',
'slogan' => 'Activepieces',
'tags' => null,
'logo' => 'images/default.webp',
'minversion' => '0.0.0',
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
Http::fake([
config('constants.services.official') => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$path = service_templates_path();
$original = File::exists($path) ? File::get($path) : null;
try {
config(['app.env' => 'production']);
(new PullTemplatesFromCDN)->handle();
$bundle = Cache::get(service_templates_cache_key());
expect($bundle)
->toBeArray()
->and($bundle)->toHaveKeys(['fetched_at', 'json'])
->and($bundle['json'])->toBe($json)
->and(File::get($path))->toBe($json)
->and(get_service_templates()->has('buzz'))->toBeTrue();
} finally {
if ($original === null) {
if (File::exists($path)) {
File::delete($path);
}
} else {
File::put($path, $original);
}
Cache::forget(service_templates_cache_key());
}
});
it('serves templates from shared cache when the local file is stale', function () {
$stalePath = service_templates_path();
$original = File::exists($stalePath) ? File::get($stalePath) : null;
$stale = json_encode(['oldservice' => ['category' => 'other', 'compose' => '']], JSON_THROW_ON_ERROR);
$fresh = json_encode([
'buzz' => ['category' => 'messaging', 'compose' => '', 'slogan' => 'Buzz'],
'newservice' => ['category' => 'other', 'compose' => ''],
], JSON_THROW_ON_ERROR);
try {
File::put($stalePath, $stale);
Cache::forever(service_templates_cache_key(), [
'fetched_at' => now()->toIso8601String(),
'json' => $fresh,
]);
$templates = get_service_templates();
expect($templates->has('buzz'))->toBeTrue()
->and($templates->has('newservice'))->toBeTrue()
->and($templates->has('oldservice'))->toBeFalse();
} finally {
if ($original === null) {
if (File::exists($stalePath)) {
File::delete($stalePath);
}
} else {
File::put($stalePath, $original);
}
Cache::forget(service_templates_cache_key());
}
});
it('falls back to the local file when shared cache is empty', function () {
Cache::forget(service_templates_cache_key());
$templates = get_service_templates();
expect($templates)->not->toBeEmpty();
});
it('skips pulling templates in local development', function () {
Http::fake();
config(['app.env' => 'local']);
(new PullTemplatesFromCDN)->handle();
Http::assertNothingSent();
expect(Cache::get(service_templates_cache_key()))->toBeNull();
});
it('logs when the CDN responds with a non-success status', function () {
Http::fake([
'cdn.coollabs.io/*' => Http::response('nope', 503),
]);
config(['app.env' => 'production']);
Log::shouldReceive('error')
->once()
->withArgs(fn (string $message, array $context = []) => $message === 'PullTemplatesFromCDN failed'
&& data_get($context, 'status') === 503);
(new PullTemplatesFromCDN)->handle();
expect(Cache::get(service_templates_cache_key()))->toBeNull();
});
+105 -10
View File
@@ -95,8 +95,8 @@ it('exits fullscreen when the terminal process exits', function () {
expect($terminalClient)
->toContain("event.data === 'pty-exited'")
->toContain('this.fullscreen = false;
this.mobileToolbarCollapsed = false;
->toContain('this.exitFullscreen();')
->toContain('this.mobileToolbarCollapsed = false;
this.terminalActive = false;');
});
@@ -146,7 +146,7 @@ it('renders a compact mobile terminal toolbar with shell control keys', function
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
@@ -169,13 +169,13 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
->toContain('navigator.clipboard.writeText(selection)');
});
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("document.getElementById('terminal')")
->toContain('terminalHeight')
->toContain('terminalWidth')
->toContain('this.fitAddon.fit()')
->toContain('terminalElement.clientHeight')
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
@@ -188,8 +188,8 @@ it('uses simple fullscreen bottom margin based on mobile toolbar visibility', fu
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->and($terminalView)
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
->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'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
@@ -203,11 +203,13 @@ it('uses fixed viewport positioning for fullscreen terminal instead of inherited
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('fixed inset-0')
->toContain('terminal-fullscreen-shell fixed inset-0')
->toContain('z-[100000]')
->toContain('h-[100dvh]')
->toContain('w-screen')
->toContain('max-w-none')
->toContain('overflow-hidden');
->toContain('overflow-hidden')
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"');
});
it('constrains normal terminal height after leaving fullscreen', function () {
@@ -216,3 +218,96 @@ it('constrains normal terminal height after leaving fullscreen', function () {
expect($terminalView)
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
});
it('keeps enter and exit fullscreen controls the same size and chrome', 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('class="terminal-fullscreen-btn fixed top-3 right-3 z-[100001]"')
->toContain("'terminal-fullscreen-btn absolute z-20'")
->and($appCss)
->toContain('.terminal-fullscreen-btn')
->toContain('width: 1.75rem')
->toContain('height: 1.75rem');
});
it('does not apply backdrop-filter on the console block itself so fixed fullscreen can escape', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
->toContain('.application-console-block::before')
->toContain('position:fixed descendants')
->toMatch('/\.application-console-block::before\s*\{[^}]*backdrop-filter/s')
->not->toMatch('/\.application-console-block\s*\{[^}]*\bbackdrop-filter\s*:/s');
});
it('keeps the terminal in the Livewire tree and unlocks ancestor stacking for fullscreen', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalClient)
->toContain('enterFullscreen()')
->toContain('exitFullscreen()')
->toContain('patchAncestorsForFullscreen(wrapper)')
->toContain('restoreAncestorsAfterFullscreen()')
->toContain('salvageStrayFullscreenNodes()')
->toContain("node.style.setProperty('isolation', 'auto', 'important')")
->toContain("node.style.setProperty('transform', 'none', 'important')")
->toContain("node.style.setProperty('z-index', 'auto', 'important')")
->toContain("main.style.setProperty('z-index', '100001', 'important')")
->toContain("fromEl.closest('main')")
->toContain('scheduleTerminalResize()')
->not->toContain('document.body.appendChild(wrapper)')
->not->toContain('enterFullscreenPortal')
->and($terminalView)
->toContain('wire:ignore')
->toContain('terminal-fullscreen-shell fixed inset-0')
->and($appCss)
->toContain('body.terminal-is-fullscreen .terminal-fullscreen-shell')
->toContain('z-index: 100000 !important');
});
it('locks page scroll while the terminal is fullscreen and unlocks on exit', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalClient)
->toContain('lockPageScroll()')
->toContain('unlockPageScroll()')
->toContain("document.body.style.setProperty('position', 'fixed', 'important')")
->toContain("window.addEventListener('wheel', this.preventPageScrollHandler")
->toContain("window.addEventListener('touchmove', this.preventPageScrollHandler")
->toContain("target.closest('.xterm-viewport')")
->toContain("document.documentElement.classList.add('terminal-is-fullscreen')")
->toContain("document.documentElement.classList.remove('terminal-is-fullscreen')")
->and($appCss)
->toContain('html.terminal-is-fullscreen')
->toContain('overscroll-behavior: none !important')
->toContain('touch-action: none');
});
it('applies the selected console theme to the fullscreen shell', 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-fullscreen-shell')
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"')
->not->toContain('bg-[#141414]')
->and($appCss)
->toContain('.terminal-fullscreen-shell')
->toContain('.terminal-fullscreen-shell[data-console-theme="shadows-cosmic-purple"]')
->toContain('#terminal.terminal-host .xterm-viewport');
});
it('fits the terminal with FitAddon and keeps xterm within the host after resize', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('this.fitAddon.fit()')
->toContain("this.term.element.style.maxHeight = '100%'")
->toContain('scrollback: 5000')
->not->toContain('Math.floor(height / charSize.height) - 1');
});
@@ -0,0 +1,77 @@
<?php
/**
* Resource layer-2 navs use one unified pill bar: menus on the left, actions on the right.
*/
it('uses a single unified navbar for application, service, database, and server headings', function () {
$files = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
];
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->toContain('resource-heading-navbar')
->toContain('justify-between')
->toContain('border-l border-neutral-200 pl-1');
}
});
it('keeps links with the menu group and actions on the right for applications and services', 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'));
$linksBeforeActionsDivider = function (string $contents, string $linksComponent): bool {
$linksPos = strpos($contents, $linksComponent);
$dividerPos = strpos($contents, 'border-l border-neutral-200 pl-1');
return $linksPos !== false && $dividerPos !== false && $linksPos < $dividerPos;
};
expect($linksBeforeActionsDivider($application, '<x-applications.links'))->toBeTrue();
expect($linksBeforeActionsDivider($service, '<x-services.links'))->toBeTrue();
// Desktop layer uses exactly one unified pill bar (mobile section may still use its own pill).
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
expect(substr_count($service, 'resource-heading-navbar'))->toBe(1);
});
it('keeps Links dropdowns outside the scrollable tabs strip', 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'));
$css = file_get_contents(resource_path('css/app.css'));
// Links must not sit inside the overflow-x-auto tabs scroller (causes scrollbar on open).
expect($application)
->toContain('resource-heading-tabs')
->toContain('resource-heading-menus')
->toContain('<x-applications.links');
$tabsBlockEnd = strpos($application, 'resource-heading-menus');
$overflowInTabs = substr(
$application,
strpos($application, 'resource-heading-tabs'),
$tabsBlockEnd - strpos($application, 'resource-heading-tabs'),
);
expect($overflowInTabs)->not->toContain('<x-applications.links');
expect(strpos($application, '<x-applications.links'))->toBeGreaterThan($tabsBlockEnd);
$serviceTabsEnd = strpos($service, 'resource-heading-menus');
$serviceOverflow = substr(
$service,
strpos($service, 'resource-heading-tabs'),
$serviceTabsEnd - strpos($service, 'resource-heading-tabs'),
);
expect($serviceOverflow)->not->toContain('<x-services.links');
expect(strpos($service, '<x-services.links'))->toBeGreaterThan($serviceTabsEnd);
expect($css)
->toContain('.resource-heading-tabs::-webkit-scrollbar')
->toContain('scrollbar-width: none');
});
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Resource Limits nav must use the reicon "cpu" glyph (not subscription card).
*/
test('application and database resource limits menus use the cpu icon', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$database = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($application)
->toMatch("/'Resource Limits'\\s*=>\\s*'cpu'/")
->not->toMatch("/'Resource Limits'\\s*=>\\s*'subscription'/");
expect($database)
->toMatch("/'label'\\s*=>\\s*'Resource Limits'[\\s\\S]{0,120}?'icon'\\s*=>\\s*'cpu'/")
->not->toMatch("/'label'\\s*=>\\s*'Resource Limits'[\\s\\S]{0,120}?'icon'\\s*=>\\s*'subscription'/");
expect($reicon)
->toContain("'cpu' =>");
});
+158
View File
@@ -0,0 +1,158 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('renders the source name as a large page title before the navbar', function () {
$githubApp = GithubApp::create([
'name' => 'coolify-laravel-dev-public',
'organization' => 'coollabsio',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'webhook_secret' => 'webhook-secret',
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$response = $this->get(route('source.github.show', ['github_app_uuid' => $githubApp->uuid]));
$response->assertSuccessful();
$response->assertSee('text-[24px]! leading-7! font-semibold! tracking-tight!', false);
$response->assertSee('order-1 mb-4 flex min-w-0 items-start justify-between gap-4 lg:order-2', false);
$response->assertSee('order-2 lg:order-1', false);
$response->assertSee('coolify-laravel-dev-public');
$response->assertSee('GitHub App for coollabsio');
$response->assertSee('>General</h3>', false);
$html = $response->getContent();
$titlePos = strpos($html, 'order-1 mb-4 flex items-start justify-between gap-4 lg:order-2');
$navbarPos = strpos($html, 'order-2 lg:order-1');
expect($titlePos)->toBeLessThan($navbarPos);
});
it('renders family page titles before tabs for team notifications and keys', function () {
$team = $this->get(route('team.index'));
$team->assertSuccessful();
$team->assertSee('Team');
$team->assertSee('Members, roles, and team settings');
$team->assertSee('New team');
// Desktop shell (lg+) hides the family H1; mobile topbar layout keeps it.
expect($team->getContent())->toContain('lg:hidden');
$this->get(route('notifications.email'))
->assertSuccessful()
->assertSee('Notifications')
->assertSee('Delivery channels for team events');
$keys = $this->get(route('security.private-key.index'));
$keys->assertSuccessful();
$keys->assertSee('Keys &amp; Tokens', false);
$keys->assertSee('SSH keys, cloud tokens, and API access');
$keys->assertSee('Private Keys');
$keys->assertSee('New private key');
$keys->assertDontSee('SSH keys available to this team');
expect($keys->getContent())->not->toContain('>Private keys</h2>');
});
it('renders the destination name as a large page title', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'prod-server']);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$destination->update(['name' => 'coolify']);
$response = $this->get(route('destination.show', ['destination_uuid' => $destination->uuid]));
$response->assertSuccessful();
$response->assertSee('text-[24px]! leading-7! font-semibold! tracking-tight!', false);
$response->assertSee('coolify');
$response->assertSee('Docker network on prod-server');
$response->assertSee('>General</h3>', false);
});
it('renders the s3 storage name as a large page title', function () {
$storage = S3Storage::create([
'name' => 'backup-bucket',
'description' => 'Primary backup destination',
'region' => 'us-east-1',
'key' => 'access-key',
'secret' => 'secret-key',
'bucket' => 'coolify-backups',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
$response = $this->get(route('storage.show', ['storage_uuid' => $storage->uuid]));
$response->assertSuccessful();
$response->assertSee('text-[24px]! leading-7! font-semibold! tracking-tight!', false);
$response->assertSee('backup-bucket');
$response->assertSee('Primary backup destination');
$response->assertSee('>General</h3>', false);
});
it('uses the same page title size on application database service and server headings', function () {
$titleClass = 'text-[24px]! leading-7! font-semibold! tracking-tight!';
$views = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/livewire/project/show.blade.php'),
resource_path('views/livewire/project/resource/index.blade.php'),
resource_path('views/components/dashboard/navbar.blade.php'),
resource_path('views/livewire/server/index.blade.php'),
resource_path('views/source/all.blade.php'),
];
foreach ($views as $view) {
expect(file_get_contents($view))->toContain($titleClass);
}
});
it('keeps new-action buttons on the same row as page titles or in layer-2 nav', function () {
$stackPattern = 'flex-col gap-3 sm:flex-row';
expect(file_get_contents(resource_path('views/livewire/server/index.blade.php')))
->toContain($stackPattern);
expect(file_get_contents(resource_path('views/source/all.blade.php')))
->toContain($stackPattern);
expect(file_get_contents(resource_path('views/livewire/destination/index.blade.php')))
->toContain($stackPattern);
expect(file_get_contents(resource_path('views/livewire/storage/index.blade.php')))
->toContain($stackPattern);
expect(file_get_contents(resource_path('views/components/dashboard/navbar.blade.php')))
->toContain('titleActions');
// Family pages put primary actions next to layer-2 tabs, not beside a desktop H1.
expect(file_get_contents(resource_path('views/livewire/security/private-key/index.blade.php')))
->toContain('<x-slot:actions>');
expect(file_get_contents(resource_path('views/components/team/navbar.blade.php')))
->toContain("'titleOnDesktop' => false")
->toContain('<x-slot:actions>')
->toContain('New team');
});
+10 -4
View File
@@ -98,12 +98,18 @@ it('renders responsive checkbox classes on the application configuration page',
$response->assertSee('value="navigate|configuration|', false);
$response->assertSee('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url', false);
$response->assertSee('application-mobile-stop-trigger');
$response->assertSee('application-mobile-deploy-trigger');
$response->assertSee('application-mobile-restart-trigger');
$response->assertSee('application-mobile-force-deploy-trigger');
$response->assertSee('Confirm Application Deployment?');
$response->assertSee('wire:click="deploy"', false);
$response->assertSee('Force deploy (without cache)');
expect(
str_contains($response->getContent(), 'wire:click="force_deploy_without_cache"')
|| str_contains($response->getContent(), 'wire:click="deploy(true)"')
)->toBeTrue();
$response->assertDontSee('Confirm Application Deployment?');
$response->assertSee('Confirm Application Restart?');
$response->assertSee('Confirm Application Force Deployment?');
$response->assertDontSee('Confirm Application Force Deployment?');
$response->assertDontSee('application-mobile-deploy-trigger');
$response->assertDontSee('application-mobile-force-deploy-trigger');
$response->assertSee('sub-menu-wrapper hidden md:flex', false);
$response->assertSee('scrollbar hidden min-h-10', false);
$response->assertSee(route('project.application.deployment.index', [
@@ -0,0 +1,54 @@
<?php
use Illuminate\Support\Facades\Blade;
test('searchable listbox renders search field and filters options client-side', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="serverTimezone" label="Server timezone"
searchPlaceholder="Search timezones" emptyText="No matching timezone"
:options="[
['value' => 'UTC', 'label' => 'UTC'],
['value' => 'Europe/Berlin', 'label' => 'Europe/Berlin'],
['value' => 'America/New_York', 'label' => 'America/New_York'],
]" :wire="false" value="UTC" />
BLADE);
expect($html)
->toContain('Server timezone')
->toContain('Search timezones')
->toContain('No matching timezone')
->toContain('serverTimezone-trigger')
->toContain('x-ref="search"')
->toContain('get filtered()')
->toContain('searchable-listbox-panel')
->toContain('Berlin')
->toContain('New_York');
});
test('server timezone fields use the searchable listbox component', function () {
$localhost = file_get_contents(resource_path('views/livewire/server/partials/localhost-general.blade.php'));
$show = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($localhost)
->toContain('x-forms.searchable-listbox id="serverTimezone"')
->toContain('searchPlaceholder="Search timezones"')
->not->toContain('<x-forms.listbox id="serverTimezone"');
expect($show)
->toContain('x-forms.searchable-listbox id="serverTimezone"')
->toContain('searchPlaceholder="Search timezones"')
->not->toMatch('/<x-forms\.listbox id="serverTimezone"/');
});
test('searchable listbox keeps the helper outside the label association', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="tz" label="Timezone"
helper="Used for cron jobs." searchPlaceholder="Search"
:options="[['value' => 'UTC', 'label' => 'UTC']]" :wire="false" value="UTC" />
BLADE);
expect($html)
->toContain('Used for cron jobs.')
->toContain('aria-label="More information"')
->not->toMatch('/<label[^>]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/');
});
@@ -176,9 +176,10 @@ describe('API validation rules for path fields', function () {
test('docker compose service domains validation rejects command injection payloads', function () {
$rules = [
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
$validator = validator(
@@ -0,0 +1,110 @@
<?php
/**
* The Sentinel page previously flashed the floating unsaved bar on open because:
* 1) wire:dirty compared the entire Livewire snapshot, and
* 2) dev-only x-init always called $wire.set('sentinelCustomDockerImage', …),
* mutating reactive state (null → '') until the round-trip cleared dirty.
*/
test('sentinel unsaved bar scopes dirty tracking to savable form fields', function () {
$path = resource_path('views/livewire/server/sentinel.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('x-unsaved-bar')
->toContain('targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
test('unsaved bar component accepts optional wire:target list', function () {
$path = resource_path('views/components/unsaved-bar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain("'targets' => null")
->toContain('wire:target="{{ $targets }}"');
});
test('unsaved bar delays show and hides while loading to avoid instant-save flash', function () {
$path = resource_path('views/components/unsaved-bar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('wire:dirty.class="is-dirty"')
->toContain('wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"')
->toContain('[&.is-dirty]:delay-300')
->toContain('delay-0');
});
test('settings general unsaved bar excludes auto-saving instance timezone', function () {
$path = resource_path('views/livewire/settings/index.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('targets="fqdn,instance_name,public_ipv4,public_ipv6,dev_helper_version"')
->toContain('id="instance_timezone"')
->toContain('onChange="submit"')
->toContain('x-forms.searchable-listbox')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
test('settings general instance timezone uses searchable listbox for label alignment with name', function () {
$path = resource_path('views/livewire/settings/index.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('x-forms.searchable-listbox id="instance_timezone"')
->toContain('id="instance_name"')
->not->toContain('selectTimezone(timezone)');
});
test('sentinel custom docker image x-init only sets wire when a value exists', function () {
$path = resource_path('views/livewire/server/sentinel.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain("x-init=\"if (customImage) { \$wire.set('sentinelCustomDockerImage', customImage) }\"")
->not->toContain("x-init=\"\$wire.set('sentinelCustomDockerImage', customImage)\"");
});
/**
* Instant-save listboxes (e.g. MCP server) entangle + call instantSave. Until the
* round-trip finishes, the component is dirty — so an unscoped unsaved bar flashes.
* Pages that mix instantSave and explicit-save fields must pass targets.
*/
test('settings advanced unsaved bar scopes dirty tracking away from instantSave fields', function () {
$path = resource_path('views/livewire/settings/advanced.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('x-unsaved-bar')
->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost"')
->toContain('onChange="instantSave"')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
test('settings updates unsaved bar scopes dirty tracking away from instantSave fields', function () {
$path = resource_path('views/livewire/settings/updates.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('targets="update_check_frequency,auto_update_frequency"')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
test('docker cleanup unsaved bar scopes dirty tracking away from instantSave fields', function () {
$path = resource_path('views/livewire/server/docker-cleanup.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('targets="dockerCleanupFrequency,dockerCleanupThreshold"')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
test('application advanced gpu unsaved bar scopes dirty tracking to gpu fields only', function () {
$path = resource_path('views/livewire/project/application/advanced.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('targets="gpuDriver,gpuCount,gpuDeviceIds,gpuOptions"');
});
@@ -0,0 +1,78 @@
<?php
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
test('danger menu is visible for non-coolify-host servers that use host.docker.internal', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'name' => 'lima-ubuntu-2404',
'ip' => 'host.docker.internal',
'port' => 2222,
])->fresh();
expect($server->getAttributes()['ip'])->toBe('host.docker.internal')
->and($server->isLocalhost())->toBeTrue()
->and($server->is_coolify_host)->toBeFalse();
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('>Danger</', false)
->assertSee(route('server.delete', ['server_uuid' => $server->uuid]), false);
});
test('danger menu is hidden for the coolify host server', function () {
$server = Server::factory()->create([
'id' => 0,
'uuid' => 'localhost',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'name' => 'localhost',
'ip' => 'host.docker.internal',
])->fresh();
expect($server->isLocalhost())->toBeTrue()
->and($server->is_coolify_host)->toBeTrue();
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertDontSee('>Danger</', false)
->assertDontSee(route('server.delete', ['server_uuid' => $server->uuid]), false);
});
test('danger page is reachable for host.docker.internal non-coolify-host servers', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'name' => 'lima-ubuntu-2404',
'ip' => 'host.docker.internal',
'port' => 2222,
]);
$this->get(route('server.delete', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('Delete server')
->assertSee('Delete '.$server->name);
});
+4 -3
View File
@@ -55,7 +55,8 @@ test('server destinations page renders when selected server has no destinations'
$this->get(route('server.destinations', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('Destinations')
->assertSee('No destinations configured for this server yet.')
->assertSee('No destinations')
->assertSee('Add a destination or scan the server for existing Docker networks.')
->assertDontSee('Server not found.');
});
@@ -76,7 +77,7 @@ test('global destinations page does not render per-server empty states beside ex
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee($serverWithDestination->standaloneDockers()->first()->name)
->assertDontSee('No destinations found.');
->assertDontSee('No destinations yet');
});
test('global destinations page renders a single empty state when no usable servers have destinations', function () {
@@ -89,7 +90,7 @@ test('global destinations page renders a single empty state when no usable serve
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee('No destinations found.');
->assertSee('No destinations yet');
});
test('adding a discovered swarm destination stores the selected network name', function () {
@@ -54,6 +54,34 @@ it('shows sentinel sync status after the server is validated', function () {
->assertSee('In sync');
});
it('places mobile status badges on a separate row below the server title', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$mobileTitleBlock = str($navbar)
->after('data-testid="server-subtitle"')
->before('id="server-mobile-actions"')
->toString();
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
expect($navbar)
->toContain('mb-3 w-full lg:hidden')
->toContain('data-testid="server-subtitle"')
->toContain('flex min-w-0 flex-col gap-2');
$titleBlock = str($navbar)
->after('mb-3 w-full lg:hidden')
->before('Phone-only actions')
->toString();
$titlePos = strpos($titleBlock, 'data-testid="server-subtitle"');
$badgesRowPos = strpos($titleBlock, 'flex min-w-0 flex-wrap items-center gap-2');
expect($titlePos)->not->toBeFalse()
->and($badgesRowPos)->not->toBeFalse()
->and($titlePos)->toBeLessThan($badgesRowPos);
});
it('listens for sentinel restarted broadcasts', function () {
[$server, , $team] = makeNavbarServer(isFunctional: true);
@@ -0,0 +1,57 @@
<?php
test('server resources managed table collapses to name and status on mobile', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.server-resources-managed-table-grid')
->toContain('.server-resources-unmanaged-table-grid');
// Isolate the ≤640px mobile block so we assert the correct breakpoint rules.
expect(preg_match(
'/@media \(max-width: 640px\)\s*\{(?P<body>.*)\n\}/s',
$css,
$matches
))->toBe(1);
$mobileCss = $matches['body'] ?? '';
// Managed: hide Project, Environment, and Type — Name + Status only.
expect($mobileCss)
->toMatch('/\.server-resources-managed-table-grid\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto;/')
->toContain('.server-resources-managed-table-grid > :nth-child(2)')
->toContain('.server-resources-managed-table-grid > :nth-child(3)')
->toContain('.server-resources-managed-table-grid > :nth-child(4)');
// Unmanaged: hide Image and Status — Name + Actions only.
expect($mobileCss)
->toMatch('/\.server-resources-unmanaged-table-grid\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto;/')
->toContain('.server-resources-unmanaged-table-grid > :nth-child(2)')
->toContain('.server-resources-unmanaged-table-grid > :nth-child(3)');
});
test('server resources table markup keeps five managed columns for progressive disclosure', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect($view)
->toContain('server-resources-managed-table-grid')
->toContain('server-resources-unmanaged-table-grid')
->toContain('<span>Name</span>')
->toContain('<span>Project</span>')
->toContain('<span>Environment</span>')
->toContain('<span>Type</span>')
->toContain('<span>Status</span>');
});
test('unmanaged container names use the same typeface as managed resource names', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
// Managed names: sans + medium weight (linked).
expect($view)
->toContain('block max-w-full truncate text-[12px] font-medium text-neutral-950 hover:underline dark:text-fg');
// Unmanaged names must match size/weight/color and not use mono.
expect($view)
->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');
});
@@ -0,0 +1,27 @@
<?php
/**
* Server security nav icons: patching uses bandage; terminal uses browser-terminal.
*/
test('server security sidebar uses bandage and browser-terminal icons', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar-security.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($sidebar)
->toMatch("/'label'\\s*=>\\s*'Server Patching'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'bandage'/")
->toMatch("/'label'\\s*=>\\s*'Terminal Access'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'browser-terminal'/")
->not->toMatch("/'label'\\s*=>\\s*'Server Patching'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'admin'/")
->not->toMatch("/'label'\\s*=>\\s*'Terminal Access'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'terminal'/");
expect($reicon)
->toContain("'bandage' =>")
->toContain("'browser-terminal' =>");
});
test('terminal access page status tile uses browser-terminal icon', function () {
$contents = file_get_contents(resource_path('views/livewire/server/security/terminal-access.blade.php'));
expect($contents)
->toContain('name="browser-terminal"')
->not->toContain('name="terminal"');
});
+389
View File
@@ -0,0 +1,389 @@
<?php
use App\Livewire\Project\Service\Domains;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
[
'id' => 0,
'is_dns_validation_enabled' => false,
]
));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->destination = StandaloneDocker::withoutEvents(function () {
return StandaloneDocker::firstOrCreate(
[
'server_id' => $this->server->id,
'network' => 'coolify',
],
[
'uuid' => (string) Str::uuid(),
'name' => 'test-docker',
]
);
});
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n",
]);
$this->webApp = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'service_id' => $this->service->id,
'name' => 'web',
'human_name' => 'Web',
'image' => 'nginx:alpine',
'fqdn' => null,
]);
$this->apiApp = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'service_id' => $this->service->id,
'name' => 'api',
'human_name' => 'API',
'image' => 'node:alpine',
'fqdn' => 'https://api.example.com',
]);
});
it('lists domains grouped by service application on the stack domains page', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSuccessful()
->assertSee('API')
->assertSee('https://api.example.com')
->assertSee('Web');
});
it('shows dns entries control next to Add', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSuccessful()
->assertSee('DNS entries')
->assertSee('Manual records');
});
it('lists dns entries for service hosts that still need dns', function () {
$this->webApp->update([
'fqdn' => 'https://web.example.com',
'domain_dns_statuses' => [
'https://web.example.com' => [
'status' => 'ok',
'message' => 'OK',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
$this->apiApp->update(['fqdn' => 'https://api.example.com,https://www.api.example.com']);
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('openDnsRecordsModal')
->assertSee('Recheck');
$names = collect($component->instance()->dnsRecordHints())->pluck('name')->all();
expect($names)
->toContain('api.example.com')
->toContain('www.api.example.com')
->not->toContain('web.example.com');
});
it('does not persist service redirect until Set Direction is called', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->webApp->id}", 'www');
expect($this->webApp->fresh()->redirect)->toBe('both')
->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
});
it('sets redirect direction per service application without changing other apps', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
$this->apiApp->update(['redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->webApp->id}", 'www')
->call('setServiceRedirect', $this->webApp->id)
->assertDispatched('success');
expect($this->webApp->fresh()->redirect)->toBe('www')
->and(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com')
->and($this->apiApp->fresh()->redirect)->toBe('both')
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
});
it('auto-adds missing non-www pair for a service application redirect', function () {
$this->apiApp->update([
'fqdn' => 'https://www.api.example.com',
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->apiApp->id}", 'non-www')
->call('setServiceRedirect', $this->apiApp->id)
->assertDispatched('success');
$this->apiApp->refresh();
expect($this->apiApp->redirect)->toBe('non-www')
->and(explode(',', (string) $this->apiApp->fqdn))
->toContain('https://www.api.example.com')
->toContain('https://api.example.com');
});
it('adds a domain to a selected service application', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://web.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
$this->webApp->refresh();
expect($this->webApp->fqdn)->toBe('https://web.example.com');
});
it('rolls back a domain change when compose regeneration fails', function () {
$this->service->update(['docker_compose_raw' => '']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://web.example.com')
->call('addDomain')
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->webApp->fresh()->fqdn)->toBeNull();
});
it('rolls back a redirect change when compose regeneration fails', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
$this->service->update(['docker_compose_raw' => '']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->webApp->id}", 'www')
->call('setServiceRedirect', $this->webApp->id)
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->webApp->fresh()->redirect)->toBe('both')
->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
});
it('prunes dns status when a service domain is removed', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
'https://api.example.com' => [
'status' => 'failed',
'message' => 'Stale DNS result.',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('removeDomain', 0)
->assertDispatched('success');
expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull();
});
it('prunes the previous dns status when a service domain is renamed', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
'https://api.example.com' => [
'status' => 'ok',
'message' => 'Stale DNS result.',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->set('editingDomain', 'https://renamed.example.com')
->call('updateDomain')
->assertHasNoErrors()
->assertDispatched('success');
$this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://renamed.example.com')
->and($this->apiApp->domain_dns_statuses)->toBeNull();
});
it('does not restore stale dns status when a removed service domain is re-added', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
'https://api.example.com' => [
'status' => 'failed',
'message' => 'Stale DNS result.',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('removeDomain', 0)
->set('newServiceApplicationId', $this->apiApp->id)
->set('newDomain', 'https://api.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
$this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://api.example.com')
->and($this->apiApp->domain_dns_statuses)->toBeNull();
});
it('saves after confirming both a domain conflict and a missing required port', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
services:
web:
image: nginx:alpine
environment:
SERVICE_URL_WEB_8000: ""
api:
image: node:alpine
YAML,
]);
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://api.example.com')
->call('addDomain')
->assertSet('showDomainConflictModal', true)
->call('confirmDomainUsage')
->assertSet('showDomainConflictModal', false)
->assertSet('showPortWarningModal', true)
->assertSet('forceSaveDomains', true);
$component
->call('confirmRemovePort')
->assertSet('showPortWarningModal', false)
->assertSet('forceSaveDomains', false)
->assertSet('forceRemovePort', false)
->assertSet('pendingAction', null)
->assertDispatched('success');
expect($this->webApp->fresh()->fqdn)->toBe('https://api.example.com');
});
it('loads persisted dns status for service applications', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
'https://api.example.com' => [
'status' => 'failed',
'message' => 'DNS mismatch stored.',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS mismatch stored.');
});
it('hides dns message text when service domain dns status is ok', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
'https://api.example.com' => [
'status' => 'ok',
'message' => 'DNS points to 203.0.113.10 (or Cloudflare).',
'expected_ip' => '203.0.113.10',
'checked_at' => now()->toIso8601String(),
],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS OK')
->assertDontSee('DNS points to 203.0.113.10')
->assertDontSee('Last checked');
});
it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call($action, ...$parameters)
->assertForbidden();
expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull();
})->with([
'all domains' => ['checkAllDns', []],
'one domain' => ['checkDomainDns', [0]],
]);
it('hides dns check controls from read-only users', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertDontSee('Recheck DNS')
->assertDontSee('Check DNS');
});
it('exposes the stack domains route', function () {
$url = route('project.service.domains', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
]);
$this->get($url)
->assertSuccessful()
->assertSeeLivewire(Domains::class)
->assertSee('Domains');
});
@@ -37,20 +37,27 @@ it('returns each service template last updated timestamp from the generated bund
});
it('prefers embedded service template git timestamps from the templates bundle', function () {
File::shouldReceive('get')
->with(base_path('templates/'.config('constants.services.file_name')))
->andReturn(json_encode([
'activepieces' => [
'documentation' => 'https://coolify.io/docs',
'slogan' => 'Open source no-code business automation.',
'compose' => '',
'tags' => null,
'category' => 'automation',
'logo' => 'images/default.webp',
'minversion' => '0.0.0',
'template_last_updated_at' => '2026-05-31T12:34:56+00:00',
],
]));
$path = base_path('templates/'.config('constants.services.file_name'));
$payload = json_encode([
'activepieces' => [
'documentation' => 'https://coolify.io/docs',
'slogan' => 'Open source no-code business automation.',
'compose' => '',
'tags' => null,
'category' => 'automation',
'logo' => 'images/default.webp',
'minversion' => '0.0.0',
'template_last_updated_at' => '2026-05-31T12:34:56+00:00',
],
]);
File::partialMock()
->shouldReceive('exists')
->with($path)
->andReturn(true)
->shouldReceive('get')
->with($path)
->andReturn($payload);
$resources = (new Select)->loadServices();
+125
View File
@@ -0,0 +1,125 @@
<?php
use App\Livewire\Settings\Advanced;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Access settings must always use the shared listbox control — not conditional
* custom Enable/Disable cards — so the Access section matches DNS/API/etc.
*/
test('settings advanced access section always uses listboxes', function () {
$path = resource_path('views/livewire/settings/advanced.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('id="is_registration_enabled"')
->toContain('id="disable_two_step_confirmation"')
->toContain('onChange="instantSave"')
->not->toContain('toggleRegistration')
->not->toContain('toggleTwoStepConfirmation')
->not->toContain('Only administrators can create accounts.')
->not->toContain('Two-step confirmations enabled');
});
test('instance admin can toggle registration via listbox instantSave', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
$settings = InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => false,
'disable_two_step_confirmation' => false,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Advanced::class)
->assertSet('is_registration_enabled', false)
->assertSee('Registration')
->assertSee('Destructive action confirmation')
->assertDontSee('Only administrators can create accounts.')
->set('is_registration_enabled', true)
->call('instantSave')
->assertDispatched('success');
expect((bool) $settings->fresh()->is_registration_enabled)->toBeTrue();
});
test('instance admin can toggle two-step confirmation via listbox instantSave', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
$settings = InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => true,
'disable_two_step_confirmation' => false,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Advanced::class)
->assertSet('disable_two_step_confirmation', false)
->set('disable_two_step_confirmation', true)
->call('instantSave')
->assertDispatched('success');
expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue();
});
test('open API allowlist warning is hidden when API access is disabled', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
InstanceSettings::forceCreate([
'id' => 0,
'is_api_enabled' => false,
'allowed_ips' => null,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Advanced::class)
->assertSet('is_api_enabled', false)
->assertDontSee('API access is open to every source');
});
test('open API allowlist warning is shown when API access is enabled without allowlist', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
InstanceSettings::forceCreate([
'id' => 0,
'is_api_enabled' => true,
'allowed_ips' => null,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Advanced::class)
->assertSet('is_api_enabled', true)
->assertSee('API access is open to every source');
});
@@ -0,0 +1,86 @@
<?php
use App\Livewire\SettingsBackup;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('empty component renders the actions slot', function () {
$html = Blade::render(<<<'BLADE'
<x-empty title="Backup is not configured" description="Needs a database." size="sm">
<x-slot:actions>
<button type="button">Configure backup</button>
</x-slot:actions>
</x-empty>
BLADE);
expect($html)
->toContain('Backup is not configured')
->toContain('Configure backup');
});
test('empty component renders the contents slot for backward compatibility', function () {
$html = Blade::render(<<<'BLADE'
<x-empty title="Metrics are disabled" size="sm">
<x-slot:contents>
<button type="button">Enable metrics</button>
</x-slot:contents>
</x-empty>
BLADE);
expect($html)
->toContain('Metrics are disabled')
->toContain('Enable metrics');
});
test('empty component prefers the actions slot when both action slots are provided', function () {
$html = Blade::render(<<<'BLADE'
<x-empty title="Conflict check" size="sm">
<x-slot:actions>
<button type="button">From actions</button>
</x-slot:actions>
<x-slot:contents>
<button type="button">From contents</button>
</x-slot:contents>
</x-empty>
BLADE);
expect($html)
->toContain('From actions')
->not->toContain('From contents');
});
test('instance backup settings show a configure button when backup is not set up', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$server = Server::factory()->create([
'id' => 0,
'team_id' => $rootTeam->id,
'ip' => '127.0.0.1',
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(SettingsBackup::class)
->assertOk()
->assertSee('Backup is not configured')
->assertSee('Configure backup')
->assertSeeHtml('wire:click="addCoolifyDatabase"');
});
+11 -4
View File
@@ -36,9 +36,16 @@ it('renders the changelog modal above the desktop sidebar toggle', function () {
}
});
Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
$component = Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
->call('openWhatsNewModal')
->assertSee('Changelog')
->assertSee('z-[60]', false)
->assertSee('closeWhatsNewModal', false);
->assertSee("What's new", false)
->assertSee('Test Release')
->assertSee('Mark read')
->assertSeeHtml('z-[99]')
->assertSeeHtml('closeWhatsNewModal');
// Single-release layout: no nested card chrome / unread left accent bar
expect($component->html())
->not->toContain('dark:bg-raised')
->not->toContain('w-0.5 bg-accent');
});
@@ -0,0 +1,72 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupInstanceAdminWithTransactionalEmail(): User
{
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
InstanceSettings::forceCreate([
'id' => 0,
'smtp_enabled' => true,
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
'smtp_host' => 'coolify-mail',
'smtp_port' => 1025,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
return $user;
}
test('send test is available in the sender section and not in the settings navbar', function () {
$user = setupInstanceAdminWithTransactionalEmail();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->assertOk()
->assertSee('Sender')
->assertSee('Send test')
->assertSeeHtml('wire:submit.prevent="sendTestEmail"');
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect($view)
->toContain('<x-settings.navbar />')
->toContain('settings-section title="Sender"')
->toContain('settings-email-send-test')
// Self-closing navbar means no actions slot is passed into the tab bar.
->not->toContain('<x-settings.navbar>');
});
test('send test is not rendered when transactional email is disabled', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
InstanceSettings::forceCreate([
'id' => 0,
'smtp_enabled' => false,
'resend_enabled' => false,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->assertOk()
->assertDontSee('Send test');
});
@@ -0,0 +1,20 @@
<?php
test('scheduled jobs refresh control lives on the activity section not the navbar', function () {
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
expect($view)
->toContain('<x-settings.navbar />')
->toContain('settings-section title="Scheduler activity"')
->toContain('wire:click="refresh"')
// Self-closing navbar means Refresh is not passed as a tab-bar action.
->not->toContain('<x-settings.navbar>');
// Refresh should appear after the activity section opens, not before.
$activityPos = strpos($view, 'settings-section title="Scheduler activity"');
$refreshPos = strpos($view, 'wire:click="refresh"');
expect($activityPos)->not->toBeFalse()
->and($refreshPos)->not->toBeFalse()
->and($refreshPos)->toBeGreaterThan($activityPos);
});
@@ -0,0 +1,36 @@
<?php
/**
* Settings / resource side navs must stay pinned while the main form column scrolls.
*/
it('pins application-settings-navigation with sticky CSS that survives body overflow-x', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
->toContain('overflow-x-clip')
->not->toContain('scrollbar overflow-x-hidden;')
->toContain('.application-settings-navigation')
->toContain('position: sticky')
->toContain('top: 6.5rem')
->toContain('max-height: calc(100dvh - 7.25rem)')
->toContain('overflow-y: auto');
});
it('marks shared settings sidebars sticky at the xl breakpoint', function () {
$paths = [
resource_path('views/livewire/project/application/configuration.blade.php'),
resource_path('views/livewire/project/database/configuration.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'),
resource_path('views/components/settings/sidebar.blade.php'),
resource_path('views/components/server/sidebar.blade.php'),
resource_path('views/components/service-database/sidebar.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('application-settings-navigation')
->toContain('xl:sticky')
->toContain('xl:top-26')
->toContain('xl:self-start');
}
});
@@ -0,0 +1,43 @@
<?php
/**
* Settings title sits above the workspace; sidebar is below it. Title shell and
* workspace share max-w-[1180px] so their left edges align.
*/
test('settings navbar and workspace share the same max width shell', function () {
$navbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php'));
expect($navbar)
->toContain('max-w-[1180px]')
->toContain("title' => 'Settings'")
->toContain(':titleOnDesktop="false"');
$pages = [
resource_path('views/livewire/settings/index.blade.php'),
resource_path('views/livewire/settings/advanced.blade.php'),
resource_path('views/livewire/settings/updates.blade.php'),
resource_path('views/livewire/settings-oauth.blade.php'),
];
foreach ($pages as $path) {
$blade = file_get_contents($path);
expect($blade)
->toContain('<x-settings.navbar')
->toContain('max-w-[1180px]')
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
->not->toContain('x-settings.page-header');
}
});
test('dashboard navbar hides family titles at lg by default', function () {
$dashboardNavbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
$settingsNavbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php'));
expect($dashboardNavbar)
->toContain("'titleOnDesktop' => false")
->toContain("'lg:hidden' => ! \$titleOnDesktop");
expect($settingsNavbar)
->toContain(':titleOnDesktop="false"');
});
+17
View File
@@ -0,0 +1,17 @@
<?php
/**
* Instance settings Updates nav must use the reicon "refresh3" glyph.
*/
test('settings updates sidebar uses the refresh3 icon', function () {
$sidebar = file_get_contents(resource_path('views/components/settings/sidebar.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($sidebar)
->toMatch("/'label'\\s*=>\\s*'Updates'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'refresh3'/")
->not->toMatch("/'label'\\s*=>\\s*'Updates'[\\s\\S]{0,200}?'icon'\\s*=>\\s*'dashboard'/");
expect($reicon)
->toContain("'refresh3' =>")
->toContain('stroke-dasharray="3 3"');
});
@@ -0,0 +1,55 @@
<?php
/**
* Shared variables family: hide the page H1 on desktop; editor view toggle lives in layer-2 nav.
*/
test('shared variables pages hide the family title on desktop', function () {
$paths = [
resource_path('views/livewire/shared-variables/index.blade.php'),
resource_path('views/livewire/shared-variables/project/index.blade.php'),
resource_path('views/livewire/shared-variables/environment/index.blade.php'),
resource_path('views/livewire/shared-variables/server/index.blade.php'),
resource_path('views/components/shared-variables/editor.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('section="shared-variables"')
->toContain(':titleOnDesktop="false"');
}
});
test('shared variables editor places the view toggle next to the tabs', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
expect($editor)
->toContain('<x-slot:actions>')
->toContain('wire:click="switch"')
->toContain('Developer view')
->toContain('Normal view');
// No longer nested as a section header action above the variable list.
expect($editor)->not->toMatch(
'/settings-section[\s\S]{0,200}<x-slot:actions>[\s\S]{0,200}wire:click="switch"/'
);
});
test('shared variables table omits resource-only flag columns', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
// Shared table: Name / Scope / Comment / Multiline only (no Literal, Buildtime, Runtime).
expect($editor)
->toContain('env-table-grid-shared')
->toContain('>Multiline</span>')
->not->toContain('>Literal</span>')
->not->toContain('>Buildtime</span>')
->not->toContain('>Runtime</span>');
expect($show)
->toContain('env-table-grid-shared')
->toContain('$isSharedVariable');
expect($css)->toContain('.env-table-grid-shared');
});
@@ -7,7 +7,15 @@ it('initializes persisted sidebar state before enabling layout transitions', fun
->toContain("collapsed: localStorage.getItem('sidebarCollapsed') === 'true'")
->toContain('sidebarReady: false')
->toContain(":class=\"[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']\"")
->toContain(":class=\"[collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]', sidebarReady ? 'transition-[padding] duration-200' : '']\"");
->toContain(":class=\"[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '']\"");
});
it('shows the coolify icon in the collapsed desktop brand slot', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('x-show="collapsed" x-cloak src="/coolify-logo.svg" alt="Coolify"')
->not->toContain('>C</span>');
});
it('does not animate navbar padding when restoring collapsed state', function () {
@@ -33,6 +33,12 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
->not->toContain('Appearance</div>')
->not->toContain('switchWidth')
->not->toContain('setZoom')
->not->toContain("localStorage.setItem('pageWidth'")
->not->toContain("localStorage.setItem('zoom'")
->not->toContain('>Width</div>')
->not->toContain('>Zoom</div>')
->not->toContain("@click=\"setTheme('dark'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('light'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('system'); dropdownOpen = false\"");
@@ -0,0 +1,74 @@
<?php
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
});
it('places connection status next to the title and delete in the danger zone', function () {
$view = file_get_contents(resource_path('views/livewire/storage/show.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($view)
->toContain('<x-slot:titleMeta>')
->toContain("'Connected'")
->toContain('storage.danger')
->toContain('Danger Zone')
->toContain('storage-danger-section')
->toContain('submitAction="delete"');
$navbarStart = strpos($view, '<x-dashboard.navbar');
$navbarEnd = strpos($view, '</x-dashboard.navbar>');
$navbarBlock = substr($view, $navbarStart, $navbarEnd - $navbarStart);
expect($navbarBlock)
->toContain('<x-slot:titleMeta>')
->not->toContain('<x-slot:actions>');
expect($navbar)
->toContain("request()->routeIs('storage.show', 'storage.danger')");
expect(file_get_contents(base_path('routes/web.php')))
->toContain("->name('storage.danger')");
});
it('renders the danger zone page for a storage destination', function () {
$storage = S3Storage::create([
'uuid' => (string) str()->uuid(),
'name' => 'minio',
'description' => 'Local MinIO',
'endpoint' => 'http://minio:9000',
'bucket' => 'coolify',
'region' => 'us-east-1',
'key' => 'access',
'secret' => 'secret',
'team_id' => $this->team->id,
'is_usable' => true,
]);
$this->get(route('storage.danger', ['storage_uuid' => $storage->uuid]))
->assertOk()
->assertSee('Danger zone', false)
->assertSee('Delete storage', false)
->assertSee('Connected', false)
->assertDontSee('Validate connection', false);
});
@@ -0,0 +1,105 @@
<?php
use App\Livewire\Subscription\Index;
use App\Livewire\Subscription\PricingPlans;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('subscription pricing page shows pay as you go plan for unsubscribed team admin', function () {
Livewire::test(Index::class)
->assertSuccessful()
->assertSee('Pay as you go')
->assertSee('Subscribe monthly')
->assertSee('Choose a plan for Coolify Cloud');
});
test('subscription pricing page still renders when provider config is missing', function () {
config()->set('subscription.provider', null);
Livewire::test(Index::class)
->assertSuccessful()
->assertSee('Pay as you go')
->assertSee('Subscribe monthly');
});
test('pricing plans component renders subscribe actions', function () {
Livewire::test(PricingPlans::class)
->assertSuccessful()
->assertSee('Pay as you go')
->assertSee('Subscribe monthly')
->assertSee('Subscribe yearly');
});
test('unsubscribed cloud sidebar does not expose global search', function () {
$response = $this->get(route('subscription.index'));
$response->assertSuccessful();
// Search button is gated; global-search listener markup may still be present.
$response->assertDontSee('title="Search (Press / or ⌘K)"', false);
$response->assertDontSee('>Search</span>', false);
});
test('subscription pricing page does not render a single-item pricing tab strip', function () {
$html = view('components.dashboard.navbar', [
'section' => 'subscription',
'title' => 'Subscription',
'subtitle' => 'Choose a plan',
])->render();
expect($html)
->toContain('Subscription')
->toContain('Choose a plan')
->not->toContain('app-tab')
->and($html)->not->toContain(route('subscription.index'));
});
test('subscription plan page does not render a single-item plan tab strip', function () {
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_active',
'stripe_customer_id' => 'cus_active',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_active',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
// Refresh memoized subscription helpers after creating the row.
Once::flush();
session(['currentTeam' => $this->team->fresh()]);
$html = view('components.dashboard.navbar', [
'section' => 'subscription',
'title' => 'Subscription',
'subtitle' => 'Plan and billing',
])->render();
expect($html)
->toContain('Subscription')
->toContain('Plan and billing')
->not->toContain('app-tab')
->and($html)->not->toContain(route('subscription.show'))
->and($html)->not->toContain(route('subscription.index'));
});
@@ -0,0 +1,80 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create([
'name' => 'Normal User',
'email_verified_at' => now(),
]);
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('allowed paths for unsubscribed accounts include profile and appearance', function () {
expect(allowedPathsForUnsubscribedAccounts())
->toContain('profile')
->toContain('profile/appearance');
});
test('unsubscribed cloud user can open profile page', function () {
$this->get(route('profile'))
->assertSuccessful()
->assertSee('Profile');
});
test('unsubscribed cloud user can open appearance page', function () {
$this->get(route('profile.appearance'))
->assertSuccessful()
->assertSee('Appearance');
});
test('unsubscribed cloud user is still redirected from workspace routes', function () {
$this->get(route('dashboard'))
->assertRedirect(route('subscription.index'));
});
test('unsubscribed cloud user does not get the workspace page switcher on profile', function () {
$response = $this->get(route('profile'));
$response->assertSuccessful();
// Static "Profile" crumb may remain; the switcher control and destinations must not.
$response->assertDontSee('title="Switch page"', false);
$response->assertDontSee('>Dashboard</span>', false);
$response->assertDontSee('>Projects</span>', false);
$response->assertDontSee(url('/projects'), false);
});
test('unsubscribed cloud sidebar shows subscription link at the top of the list', function () {
$response = $this->get(route('profile'));
$response->assertSuccessful();
$html = $response->getContent();
expect($html)
->toContain('title="Subscription"')
->toContain(route('subscription.index'))
->toContain('>Subscription</span>')
->toContain('Account')
// No spacer pushing account actions to the bottom of an empty sidebar.
->not->toContain('hidden flex-1 lg:list-item');
$accountPos = strpos($html, 'nav-section');
$subscriptionPos = strpos($html, 'title="Subscription"');
expect($accountPos)->not->toBeFalse()
->and($subscriptionPos)->not->toBeFalse()
->and($accountPos)->toBeLessThan($subscriptionPos);
});
@@ -0,0 +1,88 @@
<?php
use Illuminate\Support\Facades\Blade;
it('renders loading indicators for livewire page navigation', function () {
$html = Blade::render(
'<x-table-pagination
:from="1"
:to="10"
:total="13"
:current-page="1"
:last-page="2"
wire-target="goToPage,previousPage,nextPage"
first-action="goToPage(1)"
previous-action="previousPage"
next-action="nextPage"
last-action="goToPage(2)"
/>'
);
expect($html)
->toContain('Showing')
->toContain('1–10')
->toContain('13')
->toContain('wire:target="goToPage,previousPage,nextPage"')
->toContain('wire:loading')
->toContain('wire:loading.remove')
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.inline-flex')
->toContain('animate-spin')
->toContain('Loading page…')
->toContain('wire:click="goToPage(1)"')
->toContain('wire:click="previousPage"')
->toContain('wire:click="nextPage"')
->toContain('wire:click="goToPage(2)"')
->toContain('aria-label="First page"')
->toContain('aria-label="Next page"');
// Single spinner only (page number slot), not duplicated next to the "Showing" label.
expect(substr_count($html, 'animate-spin'))->toBe(1)
->and(substr_count($html, 'Loading page…'))->toBe(1);
});
it('omits livewire loading markup when no wire target is provided', function () {
$html = Blade::render(
'<x-table-pagination
:from="1"
:to="5"
:total="5"
:current-page="1"
:last-page="1"
/>'
);
expect($html)
->toContain('Showing')
->toContain('1–5')
->not->toContain('wire:loading')
->not->toContain('wire:target')
->not->toContain('Loading page…');
});
it('uses the shared pagination loading component on deployment history', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
expect($view)
->toContain('<x-table-pagination')
->toContain('wire-target="goToPage,previousPage,nextPage"')
->toContain('wire:loading.class="opacity-50 pointer-events-none"')
->toContain('wire:target="goToPage,previousPage,nextPage"');
});
it('uses the shared pagination loading component on environment variables', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('<x-table-pagination')
->toContain('wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"')
->toContain('wire:loading.class="opacity-50 pointer-events-none"');
});
it('uses the shared pagination loading component on team admin view', function () {
$view = file_get_contents(resource_path('views/livewire/team/admin-view.blade.php'));
expect($view)
->toContain('<x-table-pagination')
->toContain('wire-target="setPage,previousPage,nextPage"');
});
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Livewire\Tags\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('shows an empty state when there are no tags', function () {
Livewire::test(Show::class)
->assertSee('Tags')
->assertSee('No tags yet')
->assertSee('Open a resource and add a tag to start grouping related deployments.');
});
it('shows a card grid overview when tags exist and none is selected', function () {
Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
Livewire::test(Show::class)
->assertSee('Tags')
->assertSee('2 tags for bulk deploys and grouping')
->assertSee('hello')
->assertSee('production')
->assertSee('Search tags')
->assertSee('Sort')
->assertSee('Table view', false)
->assertSee('Grid view', false)
->assertSee('tagsIndex', false)
->assertSee('tags-view', false)
->assertDontSee('Switch tag')
->assertDontSee('Deploy webhook URL');
});
it('includes filterable tags payload for the overview', function () {
Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$html = Livewire::test(Show::class)->html();
expect($html)
->toContain('hello')
->toContain('production')
->toContain('resourceCount')
->toContain('applicationsCount')
->toContain('servicesCount')
->toContain('setViewMode')
->toContain('filteredTags');
});
it('shows the tag detail layout with stats when a tag is selected', function () {
$tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'name' => 'tagged-app',
]);
$application->tags()->attach($tag->id);
Livewire::test(Show::class, ['tagName' => 'hello'])
->assertSee('Tags')
->assertSee('All tags')
->assertSee('Switch tag')
->assertSee('Resources')
->assertSee('Applications')
->assertSee('Active deployments')
->assertSee('Deploy webhook URL')
->assertSee('Redeploy all')
->assertSee('tagged-app')
->assertDontSee('No resources use this tag');
});
it('shows empty resource and deployment states for an unused tag', function () {
Tag::create(['name' => 'orphan', 'team_id' => $this->team->id]);
Livewire::test(Show::class, ['tagName' => 'orphan'])
->assertSee('No resources use this tag')
->assertSee('No active deployments')
->assertSee('Using this tag')
->assertSee('Queued or running');
});
it('redirects to the overview when the requested tag does not exist', function () {
Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
Livewire::test(Show::class, ['tagName' => 'missing-tag'])
->assertRedirect(route('tags.show'));
});
@@ -0,0 +1,18 @@
<?php
/**
* Create team primary action must use the theme-aware purple/yellow accent,
* not the legacy solid coollabs purple fill.
*/
test('create team button uses theme-aware primary accent classes', function () {
$view = file_get_contents(resource_path('views/livewire/team/create.blade.php'));
expect($view)
->toContain('Create team')
->toContain('bg-coollabs/10!')
->toContain('text-coollabs!')
->toContain('dark:bg-warning/15!')
->toContain('dark:text-warning!')
->not->toContain('bg-coollabs! text-white!')
->not->toContain('hover:bg-coollabs-100!');
});
@@ -0,0 +1,37 @@
<?php
use App\Livewire\Team\Create as TeamCreate;
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::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create(['personal_team' => false]);
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'admin']);
});
test('creating a team sets is_mcp_server_enabled to true on the model', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(TeamCreate::class)
->set('name', 'MCP Safe Team')
->set('description', 'Ensures MCP default is set')
->call('submit')
->assertHasNoErrors();
$created = Team::query()->where('name', 'MCP Safe Team')->first();
expect($created)->not->toBeNull()
->and($created->is_mcp_server_enabled)->toBeTrue();
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Livewire\Team\Invitations;
use App\Livewire\Team\InviteLink;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null]));
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
});
it('shows generate link with loading state targeting viaLink', function () {
$view = file_get_contents(resource_path('views/livewire/team/invite-link.blade.php'));
expect($view)
->toContain('wire:submit="viaLink"')
->toContain('wire:target="viaLink"')
->toContain('<x-forms.button type="submit" wire:target="viaLink"')
->toContain('Generate link');
Livewire::test(InviteLink::class)
->assertSee('Generate link')
->assertSeeHtml('wire:target="viaLink"')
->assertSeeHtml('wire:loading.attr="disabled"')
->assertSeeHtml('wire:loading.class="is-loading"');
});
it('renders a real copy button for pending invitation links', function () {
$invitation = TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => 'test-invitation-uuid-123456789012',
'email' => 'invitee@example.com',
'role' => 'member',
'link' => 'http://example.test/invitations/test-invitation-uuid-123456789012',
'via' => 'link',
]);
$view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php'));
expect($view)
->toContain('aria-label="Copy invitation link"')
->toContain('window.copyToClipboard(@js($invite->link))')
->toContain('class="button h-7! shrink-0 px-2!"');
Livewire::test(Invitations::class, [
'invitations' => TeamInvitation::ownedByCurrentTeam()->get(),
])
->assertSee($invitation->link)
->assertSeeHtml('aria-label="Copy invitation link"')
->assertSeeHtml('window.copyToClipboard(')
->assertSeeHtml('type="button"');
});
it('exposes a resilient global copyToClipboard helper', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect($layout)
->toContain('async function copyToClipboard(text)')
->toContain('window.copyToClipboard = copyToClipboard')
->toContain('document.execCommand(\'copy\')')
->toContain('window.isSecureContext');
});
+2 -2
View File
@@ -301,7 +301,7 @@ describe('Destination/Show team scope', function () {
->assertSee('General')
->assertSee('Resources')
->assertDontSee('Search resources...')
->assertDontSee('No resources are using this destination.');
->assertDontSee('No resources use this destination');
});
test('mount with own standalone destination lists deployed resources', function () {
@@ -341,7 +341,7 @@ describe('Destination/Show team scope', function () {
test('mount with own standalone destination shows empty state without resources', function () {
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('No resources are using this destination.');
->assertSee('No resources use this destination');
});
test('mount with own standalone destination does not list another team resources', function () {
+115
View File
@@ -0,0 +1,115 @@
<?php
it('places the terminal helper beside the page title instead of under the subtitle', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('terminal-page-header shrink-0')
->toContain('<div class="flex items-center gap-2">')
->toContain('<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Terminal</h1>')
->toContain('<x-helper')
->toContain('Run commands on reachable servers and containers from the browser.')
->not->toContain('flex items-center gap-1.5 text-[13px] text-neutral-500 dark:text-fg-dim');
});
it('locks the terminal page to the viewport so the document does not scroll', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('class="terminal-page application-settings-form"')
->toContain('class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"')
->not->toContain('h-[calc(100dvh-11rem)]')
->not->toContain('min-h-[32rem]')
->and($appCss)
->toContain('.terminal-page')
->toContain('height: calc(100dvh - 6.75rem)')
->toContain('height: calc(100dvh - 7.25rem)')
->toContain('overflow: hidden');
});
it('labels console themes without a Shadow\'s prefix', function () {
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
foreach ([$terminalView, $consoleView] as $view) {
expect($view)
->toContain("'name' => 'Tropical Storm'")
->toContain("'name' => 'Blur Black'")
->toContain("'name' => 'Cosmic Purple'")
->not->toContain("Shadow's ");
}
});
it('shows console unavailable without the terminal shell wrapper', function () {
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$unavailableBranch = str($consoleView)
->after('@if ($consoleUnavailable)')
->before('@else')
->toString();
expect($consoleView)
->toContain('$consoleUnavailable')
->toContain('@if ($consoleUnavailable)')
->toContain('title="Console unavailable"')
->toContain('icon-name="browser-terminal"')
// Empty state must not nest inside the themed terminal chrome.
->not->toContain('No running containers');
expect($unavailableBranch)
->toContain('<x-empty')
->toContain('title="Console unavailable"')
->not->toContain('application-console-shell')
->not->toContain('application-console-header')
->not->toContain('Choose terminal theme');
});
it('shows runtime logs unavailable without log viewer chrome', function () {
$logsView = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
$unavailableBranch = str($logsView)
->after('@elseif ($logsUnavailable)')
->before('@else')
->toString();
expect($logsView)
->toContain('$logsUnavailable')
->toContain('title="Runtime logs unavailable"')
->toContain('icon-name="file-content"')
->toContain('class="mt-4 w-full lg:mt-3"')
->not->toContain('max-w-[1180px]')
->not->toContain('title="No running containers"')
->not->toContain('application-settings-form');
expect($unavailableBranch)
->toContain('<x-empty')
->toContain('title="Runtime logs unavailable"')
->not->toContain('runtime-log-shell')
->not->toContain('livewire:project.shared.get-logs')
->not->toContain('logs-viewer-toolbar');
});
it('uses server and container icons in the target list but not on the closed dropdown trigger', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain("'type' => 'server'")
->toContain("'type' => 'container'")
->toContain("x-show=\"target.type === 'server'\"")
->toContain("x-show=\"target.type === 'container'\"")
->toContain('name="servers"')
->toContain('name="layers"')
->toContain('aria-label="Choose terminal target"')
// Trigger shows label + chevron only (no leading reicon inside the button).
->toMatch('/aria-label="Choose terminal target">\s*<span class="min-w-0 truncate/');
});
it('shows loading targets without a leading terminal icon', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('Loading targets…')
->toMatch('/@if \(\$isLoadingContainers\)\s*<span class="min-w-0 truncate text-\[11px\] font-semibold text-white\/55">\s*Loading targets…/')
->not->toMatch('/@if \(\$isLoadingContainers\)\s*<x-reicon name="browser-terminal"/');
});
+20
View File
@@ -0,0 +1,20 @@
<?php
it('renders the same compact profile trigger on all breakpoints', function () {
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($menu)
->toContain('{{ $userInitial }}')
->toContain('aria-label="Account menu for {{ $userName }}"')
->not->toContain('sm:hidden')
->not->toContain('hidden sm:block max-w-[9rem]')
->not->toContain('sm:px-3');
});
it('still shows the user name and email inside the dropdown panel', function () {
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($menu)
->toContain('truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>')
->toContain('{{ $userEmail }}');
});
+4 -5
View File
@@ -318,7 +318,7 @@ it('splits scheduled backup settings and executions across dedicated urls', func
$this->get($generalUrl.'/s3')
->assertOk()
->assertSeeText('No validated S3 available. Configure one here.')
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
@@ -1143,13 +1143,12 @@ it('allows volume S3 backups to be disabled when no usable storage remains', fun
'section' => 's3',
])
->assertSet('saveToS3', true)
->assertSeeHtml('<h2>S3</h2>')
->assertSeeText('No validated S3 available. Configure one here.')
->assertSeeHtml('<h2>S3 storage</h2>')
->assertSeeText('No validated S3 storage')
->assertSeeHtml('href="'.route('storage.index').'"')
->assertSeeHtml('>here</a>')
->assertSeeText('Open S3 storage')
->assertDontSee('Save')
->assertDontSee('Disable S3')
->assertDontSee('S3 Storage')
->assertDontSee('Disable Local Backup')
->call('toggleS3')
->assertDispatched('success')
@@ -56,11 +56,10 @@ it('shows explicit loading spinners and disables cloud token action buttons whil
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
->and($serverTokenView)->toContain('wire:target="validateToken"')
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
expect($tokenFormView)->toContain('wire:target="addToken"')
->and($tokenFormView)->toContain('<x-forms.button type="submit"')
->and($tokenShowView)->toContain('wire:click="validateToken"')
->and($tokenShowView)->toContain('<x-forms.button type="button" wire:click="validateToken">')
->and($serverTokenView)->toContain('wire:click.prevent="validateToken"')
->and($serverTokenView)->toContain('<x-forms.button canGate="update" :canResource="$server"');
});
+22
View File
@@ -0,0 +1,22 @@
<?php
it('returns zero for null or empty fqdn', function () {
expect(countDomains(null))->toBe(0)
->and(countDomains(''))->toBe(0)
->and(countDomains(' '))->toBe(0);
});
it('counts a single domain', function () {
expect(countDomains('https://app.coolify.io'))->toBe(1)
->and(countDomains(' https://app.coolify.io '))->toBe(1);
});
it('counts multiple comma-separated domains', function () {
expect(countDomains('https://app.coolify.io,https://cloud.coolify.io/dashboard'))->toBe(2)
->and(countDomains('https://a.com, https://b.com , https://c.com'))->toBe(3);
});
it('ignores empty segments between commas', function () {
expect(countDomains('https://a.com,,https://b.com,'))->toBe(2)
->and(countDomains(',,,'))->toBe(0);
});
+48
View File
@@ -0,0 +1,48 @@
<?php
it('returns A for ipv4 addresses', function () {
expect(dnsRecordTypeForIp('203.0.113.10'))->toBe('A')
->and(dnsRecordTypeForIp('172.16.0.3 (coolify-testing-host)'))->toBe('A');
});
it('returns AAAA for ipv6 addresses', function () {
expect(dnsRecordTypeForIp('2001:db8::1'))->toBe('AAAA')
->and(dnsRecordTypeForIp('[2001:db8::1]'))->toBe('AAAA')
->and(dnsRecordTypeForIp('2001:db8::1 (my-host)'))->toBe('AAAA');
});
it('defaults to A when the address is missing or not an ip', function () {
expect(dnsRecordTypeForIp(null))->toBe('A')
->and(dnsRecordTypeForIp(''))->toBe('A')
->and(dnsRecordTypeForIp('coolify-testing-host'))->toBe('A');
});
it('builds a short A-record guidance message for ipv4 targets', function () {
$message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3');
expect($message)->toBe('A record → 172.16.0.3')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
it('builds a short AAAA-record guidance message for ipv6 targets', function () {
$message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1');
expect($message)->toBe('AAAA record → 2001:db8::1')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
it('prefers the bare ip over a hostname label', function () {
expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3'))
->toBe('A record → 172.16.0.3');
});
it('falls back when no target is available', function () {
expect(dnsMismatchGuidanceMessage(null))->toBe('DNS validation failed. Check your DNS records.');
});
it('accepts Cloudflare ipv6 proxy addresses', function () {
expect(isCloudflareIp('2606:4700:3037::6815:4f2b'))->toBeTrue()
->and(isCloudflareIp('2001:db8::1'))->toBeFalse();
});
+95
View File
@@ -0,0 +1,95 @@
<?php
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
it('returns a literal ipv4 address unchanged', function () {
$result = resolveServerIpAddress('203.0.113.10');
expect($result['ip'])->toBe('203.0.113.10')
->and($result['configured'])->toBe('203.0.113.10')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('returns a literal ipv6 address unchanged', function () {
$result = resolveServerIpAddress('2001:db8::1');
expect($result['ip'])->toBe('2001:db8::1')
->and($result['configured'])->toBe('2001:db8::1')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('strips brackets from ipv6 addresses', function () {
$result = resolveServerIpAddress('[2001:db8::1]');
expect($result['ip'])->toBe('2001:db8::1')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('resolves localhost hostname to a real ip address', function () {
$result = resolveServerIpAddress('localhost');
expect($result['configured'])->toBe('localhost')
->and($result['resolved_from_hostname'])->toBeTrue()
->and($result['ip'])->not->toBeNull()
->and(filter_var($result['ip'], FILTER_VALIDATE_IP))->not->toBeFalse();
});
it('returns nulls for empty input', function () {
expect(resolveServerIpAddress(null))->toMatchArray([
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
])->and(resolveServerIpAddress(''))->toMatchArray([
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
]);
});
it('keeps configured hostname when it cannot be resolved', function () {
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
$result = resolveServerIpAddress($host);
expect($result['configured'])->toBe($host)
->and($result['ip'])->toBeNull()
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('caches successful hostname resolutions for subsequent lookups', function () {
Cache::flush();
$result = resolveServerIpAddress('localhost');
expect($result['ip'])->not->toBeNull()
->and(Cache::get('server-ip-resolve:localhost'))->toBe($result['ip']);
// A fresh cache hit must be preferred over re-querying DNS.
Cache::put('server-ip-resolve:localhost', '203.0.113.99', 60);
$cached = resolveServerIpAddress('localhost');
expect($cached['ip'])->toBe('203.0.113.99')
->and($cached['configured'])->toBe('localhost')
->and($cached['resolved_from_hostname'])->toBeTrue();
});
it('does not cache failed hostname resolutions', function () {
Cache::flush();
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
resolveServerIpAddress($host);
expect(Cache::get('server-ip-resolve:'.mb_strtolower($host)))->toBeNull();
});
it('does not use the cache for literal ip addresses', function () {
Cache::put('server-ip-resolve:203.0.113.10', '198.51.100.1', 60);
$result = resolveServerIpAddress('203.0.113.10');
expect($result['ip'])->toBe('203.0.113.10')
->and($result['resolved_from_hostname'])->toBeFalse();
});
Loaded 100 of 497 files, more files were not shown because too many files have changed in this diff. Show more