From 07933640f2c662d5225504bb281bbd7675d4fdde Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:43:45 +0200 Subject: [PATCH] test(browser): expand config coverage and shared helpers Add BrowserTestHelpers and configuration/deploy browser tests for apps, databases, services, and Livewire toggles. Refactor existing browser suites onto shared seed/login helpers, soft-skip onboarding, and null broadcasting so host-side Pest runs avoid docker DNS. --- phpunit.xml | 2 + tests/Pest.php | 23 +- tests/Support/BrowserTestHelpers.php | 338 ++++++++++++++++++ .../Browser/ApplicationConfigurationTest.php | 153 ++++++++ tests/v4/Browser/AuthorizationTest.php | 105 ++---- tests/v4/Browser/DashboardTest.php | 68 +--- .../v4/Browser/DatabaseConfigurationTest.php | 164 +++++++++ .../DeploymentConfigReflectionTest.php | 128 +++++++ .../Browser/LivewireToggleEdgeCasesTest.php | 278 ++++++++++++++ tests/v4/Browser/LoginTest.php | 69 +++- tests/v4/Browser/ProjectAndServerTest.php | 137 +++++++ tests/v4/Browser/RegistrationTest.php | 30 +- .../ResourceSettingsPersistenceTest.php | 147 ++------ tests/v4/Browser/ServiceConfigurationTest.php | 136 +++++++ 14 files changed, 1508 insertions(+), 270 deletions(-) create mode 100644 tests/Support/BrowserTestHelpers.php create mode 100644 tests/v4/Browser/ApplicationConfigurationTest.php create mode 100644 tests/v4/Browser/DatabaseConfigurationTest.php create mode 100644 tests/v4/Browser/DeploymentConfigReflectionTest.php create mode 100644 tests/v4/Browser/LivewireToggleEdgeCasesTest.php create mode 100644 tests/v4/Browser/ProjectAndServerTest.php create mode 100644 tests/v4/Browser/ServiceConfigurationTest.php diff --git a/phpunit.xml b/phpunit.xml index dffa7eb49..0ceaab549 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -25,6 +25,8 @@ + + diff --git a/tests/Pest.php b/tests/Pest.php index 638ccea0c..a0b87051f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,6 +26,7 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser'); */ require_once __DIR__.'/Support/V5TestHelpers.php'; +require_once __DIR__.'/Support/BrowserTestHelpers.php'; /* |-------------------------------------------------------------------------- @@ -41,15 +42,33 @@ beforeEach(function () { // Flush the Server identity map cache to ensure tests get fresh data Server::flushIdentityMap(); + + // Browser Livewire actions often dispatch events; the Soketi host is not + // resolvable from host-side Pest runs (docker DNS name coolify-realtime). + config(['broadcasting.default' => 'null']); }); function loginAndSkipBoarding(string $email = 'test@example.com', string $password = 'password'): mixed { - return visit('/login') + $page = visit('/login') ->fill('email', $email) ->fill('password', $password) ->click('Login') - ->click('Skip Setup'); + ->wait(1.5); + + // First-login root users land on onboarding; skip when the control exists. + $page->script(<<<'JS' + (() => { + const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]')); + const skip = candidates.find((el) => (el.textContent || '').trim().toLowerCase() === 'skip setup'); + if (skip) { + skip.click(); + } + })() + JS); + $page->wait(1.5); + + return $page; } /* diff --git a/tests/Support/BrowserTestHelpers.php b/tests/Support/BrowserTestHelpers.php new file mode 100644 index 000000000..79a973c7f --- /dev/null +++ b/tests/Support/BrowserTestHelpers.php @@ -0,0 +1,338 @@ + 0, + 'is_sponsorship_popup_enabled' => false, + 'is_registration_enabled' => true, + ], $attributes)); +} + +/** + * Root user (id 0) with known credentials for browser login. + */ +function createBrowserRootUser( + string $email = 'test@example.com', + string $password = 'password', + string $name = 'Root User', +): User { + return User::forceCreate([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make($password), + ]); +} + +/** + * Development-style OpenSSH private key used across browser fixtures. + */ +function browserTestPrivateKeyPem(): string +{ + return <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; +} + +/** + * Seed instance settings, root user, SSH key, localhost server, destination, + * project and default environment for resource browser tests. + * + * @return array{ + * user: User, + * privateKey: PrivateKey, + * server: Server, + * destination: StandaloneDocker, + * project: Project, + * environment: Environment + * } + */ +function seedBrowserResourceStack(array $overrides = []): array +{ + seedBrowserInstanceSettings($overrides['instanceSettings'] ?? []); + + $user = createBrowserRootUser( + $overrides['email'] ?? 'test@example.com', + $overrides['password'] ?? 'password', + $overrides['name'] ?? 'Root User', + ); + + $privateKey = PrivateKey::create([ + 'id' => 1, + 'uuid' => $overrides['privateKeyUuid'] ?? 'ssh-test', + 'team_id' => 0, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => browserTestPrivateKeyPem(), + ]); + + $server = Server::create([ + 'id' => 0, + 'uuid' => $overrides['serverUuid'] ?? 'localhost', + 'name' => $overrides['serverName'] ?? 'localhost', + 'description' => $overrides['serverDescription'] ?? 'Test docker container in development', + 'ip' => $overrides['serverIp'] ?? 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => $privateKey->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + if ($server->settings) { + $server->settings->is_reachable = true; + $server->settings->is_usable = true; + $server->settings->save(); + } + + $destination = null; + StandaloneDocker::withoutEvents(function () use ($server, &$destination, $overrides) { + $destination = StandaloneDocker::firstOrCreate( + ['server_id' => $server->id, 'network' => $overrides['network'] ?? 'coolify'], + [ + 'uuid' => $overrides['destinationUuid'] ?? 'docker-destination-1', + 'name' => $overrides['destinationName'] ?? 'coolify', + ] + ); + }); + + $project = Project::create([ + 'uuid' => $overrides['projectUuid'] ?? 'project-browser', + 'name' => $overrides['projectName'] ?? 'Browser Project', + 'description' => $overrides['projectDescription'] ?? 'Browser test project', + 'team_id' => 0, + ]); + + $environment = $project->environments()->first(); + + Team::query()->whereKey(0)->update(['show_boarding' => false]); + + return compact('user', 'privateKey', 'server', 'destination', 'project', 'environment'); +} + +function createBrowserApplication(array $stack, array $attributes = []): Application +{ + return Application::factory()->create(array_merge([ + 'uuid' => $attributes['uuid'] ?? 'app-browser-'.Str::lower(Str::random(8)), + 'name' => $attributes['name'] ?? 'Browser App', + 'description' => $attributes['description'] ?? 'Browser test application', + 'git_repository' => $attributes['git_repository'] ?? 'https://github.com/coollabsio/coolify.git', + 'git_branch' => $attributes['git_branch'] ?? 'main', + 'build_pack' => $attributes['build_pack'] ?? 'nixpacks', + 'ports_exposes' => $attributes['ports_exposes'] ?? '3000', + 'environment_id' => $stack['environment']->id, + 'destination_id' => $stack['destination']->id, + 'destination_type' => $stack['destination']->getMorphClass(), + ], $attributes)); +} + +function createBrowserPostgresql(array $stack, array $attributes = []): StandalonePostgresql +{ + return StandalonePostgresql::create(array_merge([ + 'uuid' => $attributes['uuid'] ?? 'db-pg-'.Str::lower(Str::random(8)), + 'name' => $attributes['name'] ?? 'Browser Postgres', + 'description' => $attributes['description'] ?? 'Browser test database', + 'postgres_user' => $attributes['postgres_user'] ?? 'postgres', + 'postgres_password' => $attributes['postgres_password'] ?? 'postgres-password', + 'postgres_db' => $attributes['postgres_db'] ?? 'postgres', + 'image' => $attributes['image'] ?? 'postgres:15-alpine', + 'status' => $attributes['status'] ?? 'exited', + 'environment_id' => $stack['environment']->id, + 'destination_id' => $stack['destination']->id, + 'destination_type' => $stack['destination']->getMorphClass(), + ], $attributes)); +} + +function createBrowserRedis(array $stack, array $attributes = []): StandaloneRedis +{ + // redis_password was moved to environment variables (no column on standalone_redis). + return StandaloneRedis::forceCreate(array_merge([ + 'uuid' => $attributes['uuid'] ?? 'db-redis-'.Str::lower(Str::random(8)), + 'name' => $attributes['name'] ?? 'Browser Redis', + 'description' => $attributes['description'] ?? 'Browser test redis', + 'image' => $attributes['image'] ?? 'redis:7-alpine', + 'status' => $attributes['status'] ?? 'exited', + 'environment_id' => $stack['environment']->id, + 'destination_id' => $stack['destination']->id, + 'destination_type' => $stack['destination']->getMorphClass(), + ], $attributes)); +} + +/** + * @return array{service: Service, serviceApplication: ServiceApplication} + */ +function createBrowserService(array $stack, array $attributes = []): array +{ + $service = Service::factory()->create(array_merge([ + 'uuid' => $attributes['uuid'] ?? 'svc-'.Str::lower(Str::random(8)), + 'name' => $attributes['name'] ?? 'Browser Service', + 'description' => $attributes['description'] ?? 'Browser test compose service', + 'environment_id' => $stack['environment']->id, + 'server_id' => $stack['server']->id, + 'destination_id' => $stack['destination']->id, + 'destination_type' => $stack['destination']->getMorphClass(), + 'docker_compose_raw' => $attributes['docker_compose_raw'] ?? "services:\n web:\n image: nginx:alpine\n ports:\n - '80'\n", + ], $attributes)); + + $serviceApplication = ServiceApplication::forceCreate([ + 'uuid' => $attributes['serviceApplicationUuid'] ?? (string) Str::uuid(), + 'name' => $attributes['serviceApplicationName'] ?? 'web', + 'service_id' => $service->id, + 'image' => $attributes['image'] ?? 'nginx:alpine', + ]); + + return compact('service', 'serviceApplication'); +} + +function applicationConfigurationUrl(Project $project, $environment, Application $application): string +{ + return "/project/{$project->uuid}/environment/{$environment->uuid}/application/{$application->uuid}"; +} + +function databaseConfigurationUrl(Project $project, $environment, StandalonePostgresql|StandaloneRedis $database): string +{ + return "/project/{$project->uuid}/environment/{$environment->uuid}/database/{$database->uuid}"; +} + +function serviceConfigurationUrl(Project $project, $environment, Service $service): string +{ + return "/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}"; +} + +/** + * Choose an option from a Coolify Alpine listbox (`x-forms.listbox`). + * + * Opens the trigger `#{$id}-trigger`, then clicks the option whose label matches + * within that listbox only (avoids matching same labels on other listboxes, e.g. + * Build strategy "Static" vs Site type "Static"). + * + * Uses DOM evaluation for the option click so labels with CSS-special characters + * like parentheses (e.g. "SPA (single-page application)", "allow (insecure)") work. + */ +function selectListboxOption(mixed $page, string $id, string $optionLabel, float $waitSeconds = 1.5): void +{ + $escapedId = json_encode($id, JSON_THROW_ON_ERROR); + $escapedLabel = json_encode($optionLabel, JSON_THROW_ON_ERROR); + + $opened = $page->script(<< { + const id = {$escapedId}; + const trigger = document.querySelector('#' + id + '-trigger'); + if (!trigger) { + return 'missing-trigger'; + } + trigger.click(); + return 'opened'; + })() + JS); + + if ($opened !== 'opened') { + throw new RuntimeException("Listbox trigger #{$id}-trigger not found."); + } + + $page->wait(0.4); + + $clicked = $page->script(<< { + const id = {$escapedId}; + const label = {$escapedLabel}; + const trigger = document.querySelector('#' + id + '-trigger'); + if (!trigger) { + return 'missing-trigger'; + } + + // Scope to the listbox root that owns this trigger. + const root = trigger.closest('.relative') || trigger.parentElement; + const options = Array.from((root || document).querySelectorAll('[role="option"]')); + const match = options.find((el) => { + if (el.offsetParent === null && getComputedStyle(el).display === 'none') { + return false; + } + const text = (el.textContent || '').replace(/\\s+/g, ' ').trim(); + return text === label; + }); + + if (!match) { + return 'missing-option:' + options.map((el) => (el.textContent || '').trim()).join('|'); + } + + match.click(); + return 'clicked'; + })() + JS); + + if ($clicked !== 'clicked') { + throw new RuntimeException("Listbox option [{$optionLabel}] not selected for #{$id}: {$clicked}"); + } + + $page->wait($waitSeconds); +} + +/** + * Submit the primary Livewire settings form on the page. + */ +function submitLivewireForm(mixed $page, string $submitAction = 'submit'): void +{ + $escaped = addcslashes($submitAction, '"\\'); + $page->script(<< { + const forms = Array.from(document.querySelectorAll('form')); + const form = forms.find((candidate) => { + const attrs = candidate.getAttributeNames(); + return attrs.some((name) => name.startsWith('wire:submit') && candidate.getAttribute(name) === "{$escaped}"); + }) || forms.find((candidate) => candidate.getAttributeNames().some((name) => name.startsWith('wire:submit'))) + || document.querySelector('form.application-settings-form'); + + if (!form) { + return; + } + + // Prefer Livewire's submit hook when available; fall back to native submit. + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + } + })() + JS); + $page->wait(2); +} diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php new file mode 100644 index 000000000..75934361b --- /dev/null +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -0,0 +1,153 @@ +stack = seedBrowserResourceStack(); + $this->application = createBrowserApplication($this->stack, [ + 'uuid' => 'app-browser-config', + 'name' => 'Config App', + 'ports_exposes' => '3000', + 'custom_docker_run_options' => null, + ]); +}); + +it('shows application configuration sections and navigation', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + + $page->assertSee('Config App') + ->assertSee('General') + ->assertSee('Environment Variables') + ->assertSee('Danger Zone') + ->assertSee('Application details') + ->assertSee('Build pipeline') + ->screenshot(filename: 'application-configuration-overview'); +}); + +it('saves application name description and ports from the general form', function () { + loginAndSkipBoarding(); + + $updatedName = 'App UI '.(string) new Cuid2; + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + $page->assertSee('General') + ->fill('name', $updatedName) + ->fill('description', 'Updated via browser test') + ->fill('portsExposes', '8080') + ->screenshot(filename: 'application-general-before-save'); + + submitLivewireForm($page); + + $page->assertValue('name', $updatedName) + ->screenshot(filename: 'application-general-after-save'); + + $this->application->refresh(); + expect($this->application->name)->toBe($updatedName) + ->and($this->application->description)->toBe('Updated via browser test') + ->and($this->application->ports_exposes)->toBe('8080'); + + $reloaded = visit($url); + $reloaded->assertValue('name', $updatedName) + ->assertValue('description', 'Updated via browser test') + ->assertValue('portsExposes', '8080') + ->screenshot(filename: 'application-general-reloaded'); +}); + +it('saves custom docker run options from the UI', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + $options = '--hostname=browser-app --cap-add=SYS_ADMIN'; + + $page = visit($url); + $page->fill('customDockerRunOptions', $options); + submitLivewireForm($page); + + $this->application->refresh(); + expect($this->application->custom_docker_run_options)->toBe($options); + + $page->assertValue('customDockerRunOptions', $options) + ->screenshot(filename: 'application-docker-run-options'); +}); + +it('opens environment variables page for the application', function () { + loginAndSkipBoarding(); + + $base = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit("{$base}/environment-variables"); + + $page->assertSee('Environment Variables') + ->assertSee('Config App') + ->screenshot(filename: 'application-environment-variables'); +}); + +it('opens advanced and healthcheck configuration pages', function () { + loginAndSkipBoarding(); + + $base = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + visit("{$base}/advanced") + ->assertSee('Config App') + ->screenshot(filename: 'application-advanced'); + + visit("{$base}/healthcheck") + ->assertSee('Config App') + ->screenshot(filename: 'application-healthcheck'); +}); + +it('lists the application on the environment resources page', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + + $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}"); + + $page->assertSee('Config App') + ->screenshot(filename: 'environment-lists-application'); +}); + +it('shows danger zone for application deletion', function () { + loginAndSkipBoarding(); + + $base = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit("{$base}/danger"); + + $page->assertSee('Danger') + ->assertSee('Config App') + ->screenshot(filename: 'application-danger-zone'); +}); diff --git a/tests/v4/Browser/AuthorizationTest.php b/tests/v4/Browser/AuthorizationTest.php index cb26b73a9..94183aa18 100644 --- a/tests/v4/Browser/AuthorizationTest.php +++ b/tests/v4/Browser/AuthorizationTest.php @@ -2,10 +2,9 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; -use App\Models\InstanceSettings; -use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; @@ -13,46 +12,13 @@ use Illuminate\Support\Facades\Hash; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]); - - // Create root/owner user - $this->user = User::factory()->create([ - 'id' => 0, - 'name' => 'Root User', - 'email' => 'test@example.com', - 'password' => Hash::make('password'), - ]); - - // Create SSH key for the root user's team - PrivateKey::create([ - 'id' => 1, - 'uuid' => 'ssh-test', - 'team_id' => 0, - 'name' => 'Test Key', - 'description' => 'Test SSH key', - 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----', - ]); - - // Create servers for testing - Server::create([ - 'id' => 0, - 'uuid' => 'localhost', - 'name' => 'localhost', - 'description' => 'Test docker container in development', - 'ip' => 'coolify-testing-host', - 'team_id' => 0, - 'private_key_id' => 1, - 'proxy' => [ - 'type' => ProxyTypes::TRAEFIK->value, - 'status' => ProxyStatus::EXITED->value, - ], + $this->stack = seedBrowserResourceStack([ + 'projectUuid' => 'project-1', + 'projectName' => 'My first project', + 'projectDescription' => 'This is a test project', + 'serverDescription' => 'Test docker container in development', ]); + $this->user = $this->stack['user']; Server::create([ 'uuid' => 'production-1', @@ -67,14 +33,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ], ]); - // Create projects for testing - Project::create([ - 'uuid' => 'project-1', - 'name' => 'My first project', - 'description' => 'This is a test project', - 'team_id' => 0, - ]); - Project::create([ 'uuid' => 'project-2', 'name' => 'Production API', @@ -94,6 +52,9 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== $personalTeam->delete(); // Attach member to root team (id=0) with 'member' role $this->member->teams()->attach(0, ['role' => 'member']); + + // Skip onboarding UI for both owner and member sessions. + Team::query()->whereKey(0)->update(['show_boarding' => false]); }); function loginAsMember(): mixed @@ -101,7 +62,8 @@ function loginAsMember(): mixed return visit('/login') ->fill('email', 'member@example.com') ->fill('password', 'password') - ->click('Login'); + ->click('Login') + ->wait(1); } it('redirects unauthenticated users to login', function () { @@ -115,7 +77,8 @@ it('shows dashboard after successful login and onboarding skip', function () { $page = loginAndSkipBoarding(); $page->assertSee('Dashboard') - ->assertSee('Your self-hosted infrastructure') + ->assertSee('Projects') + ->assertSee('Servers') ->screenshot(); }); @@ -147,7 +110,9 @@ it('allows authenticated users to access team settings', function () { $page = visit('/team'); $page->assertSee('General') - ->assertSee('Manage the general settings of this team') + ->assertSee('Name') + ->assertSee('MCP server') + ->assertSee('Root Team') ->screenshot(); }); @@ -156,8 +121,8 @@ it('shows danger zone to team owner', function () { $page = visit('/team'); - $page->assertSee('Danger Zone') - ->assertSee('Delete Team') + $page->assertSee('Danger zone') + ->assertSee('Destructive actions for this team.') ->screenshot(); }); @@ -248,8 +213,8 @@ it('member does not see danger zone on team settings', function () { $page = visit('/team'); $page->assertSee('General') - ->assertDontSee('Danger Zone') - ->assertDontSee('Delete Team') + ->assertDontSee('Danger zone') + ->assertDontSee('Destructive actions for this team.') ->screenshot(); }); @@ -315,8 +280,9 @@ it('member does not see add environment button on project page', function () { $project = Project::where('uuid', 'project-1')->first(); $page = visit("/project/{$project->uuid}"); - $page->assertSee('Environments') - ->assertDontSee('+ Add') + $page->assertSee('My first project') + ->assertSee('in this project') + ->assertDontSee('New environment') ->screenshot(); }); @@ -326,7 +292,7 @@ it('member does not see environment settings link on project page', function () $project = Project::where('uuid', 'project-1')->first(); $page = visit("/project/{$project->uuid}"); - $page->assertSee('Environments') + $page->assertSee('My first project') ->assertDontSee('Settings') ->screenshot(); }); @@ -337,8 +303,8 @@ it('owner sees add environment and settings on project page', function () { $project = Project::where('uuid', 'project-1')->first(); $page = visit("/project/{$project->uuid}"); - $page->assertSee('Environments') - ->assertSee('+ Add') + $page->assertSee('My first project') + ->assertSee('New environment') ->assertSee('Settings') ->screenshot(); }); @@ -350,7 +316,8 @@ it('member does not see add resource link on dashboard project cards', function $page->assertSee('Projects') ->assertSee('My first project') - ->assertDontSee('+ Add Resource') + ->assertDontSee('Add resource to My first project') + ->assertSourceMissing('title="Add resource"') ->screenshot(); }); @@ -361,17 +328,16 @@ it('owner sees add resource link on dashboard project cards', function () { $page->assertSee('Projects') ->assertSee('My first project') - ->assertSee('+ Add Resource') + ->assertSourceHas('title="Add resource"') ->screenshot(); }); it('member does not see project settings link on dashboard', function () { $page = loginAsMember(); - // The "Settings" link is inside the project card on the dashboard - // Member should not see it due to @can('update', $project) + // Project settings control is an icon link with this aria-label for owners only. $page->assertSee('My first project') - ->assertDontSee('Settings') + ->assertSourceMissing('Open settings for My first project') ->screenshot(); }); @@ -383,7 +349,7 @@ it('member does not see invite form on team members page', function () { $page = visit('/team/members'); $page->assertSee('Members') - ->assertDontSee('Invite New Member') + ->assertDontSee('Invite a member') ->screenshot(); }); @@ -393,7 +359,7 @@ it('owner sees invite form on team members page', function () { $page = visit('/team/members'); $page->assertSee('Members') - ->assertSee('Invite New Member') + ->assertSee('Invite a member') ->screenshot(); }); @@ -441,7 +407,8 @@ it('member does not see delete project button on project page', function () { $project = Project::where('uuid', 'project-1')->first(); $page = visit("/project/{$project->uuid}"); - $page->assertSee('Environments') + $page->assertSee('My first project') ->assertDontSee('Delete Project') + ->assertDontSee('Delete project') ->screenshot(); }); diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php index d50fceb0b..3a50e1954 100644 --- a/tests/v4/Browser/DashboardTest.php +++ b/tests/v4/Browser/DashboardTest.php @@ -2,53 +2,19 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; -use App\Models\InstanceSettings; -use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; -use App\Models\User; +use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Hash; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]); - - $this->user = User::factory()->create([ - 'id' => 0, - 'name' => 'Root User', - 'email' => 'test@example.com', - 'password' => Hash::make('password'), - ]); - - PrivateKey::create([ - 'id' => 1, - 'uuid' => 'ssh-test', - 'team_id' => 0, - 'name' => 'Test Key', - 'description' => 'Test SSH key', - 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----', - ]); - - Server::create([ - 'id' => 0, - 'uuid' => 'localhost', - 'name' => 'localhost', - 'description' => 'This is a test docker container in development mode', - 'ip' => 'coolify-testing-host', - 'team_id' => 0, - 'private_key_id' => 1, - 'proxy' => [ - 'type' => ProxyTypes::TRAEFIK->value, - 'status' => ProxyStatus::EXITED->value, - ], + $this->stack = seedBrowserResourceStack([ + 'projectUuid' => 'project-1', + 'projectName' => 'My first project', + 'projectDescription' => 'This is a test project in development', + 'serverDescription' => 'This is a test docker container in development mode', ]); Server::create([ @@ -77,13 +43,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ], ]); - Project::create([ - 'uuid' => 'project-1', - 'name' => 'My first project', - 'description' => 'This is a test project in development', - 'team_id' => 0, - ]); - Project::create([ 'uuid' => 'project-2', 'name' => 'Production API', @@ -107,23 +66,28 @@ it('redirects to login when not authenticated', function () { }); it('shows onboarding after first login', function () { + // seedBrowserResourceStack disables boarding for most browser flows; re-enable for this case. + Team::query()->whereKey(0)->update(['show_boarding' => true]); + $page = visit('/login'); $page->fill('email', 'test@example.com') ->fill('password', 'password') ->click('Login') + ->wait(1.5) ->assertSee('Welcome to Coolify') - ->assertSee("Let's go!") - ->assertSee('Skip Setup') - ->screenshot(); + ->assertSee('Continue') + ->assertSee('Skip setup') + ->screenshot(filename: 'dashboard-onboarding-after-login'); }); it('shows dashboard after skipping onboarding', function () { $page = loginAndSkipBoarding(); $page->assertSee('Dashboard') - ->assertSee('Your self-hosted infrastructure.') - ->screenshot(); + ->assertSee('Projects') + ->assertSee('Servers') + ->screenshot(filename: 'dashboard-after-onboarding'); }); it('shows all projects on dashboard', function () { diff --git a/tests/v4/Browser/DatabaseConfigurationTest.php b/tests/v4/Browser/DatabaseConfigurationTest.php new file mode 100644 index 000000000..11a6c01fe --- /dev/null +++ b/tests/v4/Browser/DatabaseConfigurationTest.php @@ -0,0 +1,164 @@ +stack = seedBrowserResourceStack(); + $this->postgres = createBrowserPostgresql($this->stack, [ + 'uuid' => 'db-browser-pg', + 'name' => 'Config Postgres', + 'description' => 'Initial postgres description', + ]); + $this->redis = createBrowserRedis($this->stack, [ + 'uuid' => 'db-browser-redis', + 'name' => 'Config Redis', + ]); +}); + +it('shows postgres configuration sections', function () { + loginAndSkipBoarding(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + + $page->assertSee('Config Postgres') + ->assertSee('General') + ->assertSee('Environment Variables') + ->assertSee('Persistent Storage') + ->assertSee('Danger Zone') + ->assertSee('Username') + ->assertSee('Initial database') + ->screenshot(filename: 'database-postgres-configuration'); +}); + +it('saves postgres name description and credentials fields', function () { + loginAndSkipBoarding(); + + $updatedName = 'Postgres UI '.(string) new Cuid2; + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + $page->fill('name', $updatedName) + ->fill('description', 'Updated postgres description') + ->fill('postgresDb', 'coolify_browser') + ->screenshot(filename: 'database-postgres-before-save'); + + submitLivewireForm($page); + + $this->postgres->refresh(); + expect($this->postgres->name)->toBe($updatedName) + ->and($this->postgres->description)->toBe('Updated postgres description') + ->and($this->postgres->postgres_db)->toBe('coolify_browser'); + + visit($url) + ->assertValue('name', $updatedName) + ->assertValue('postgresDb', 'coolify_browser') + ->screenshot(filename: 'database-postgres-after-reload'); +}); + +it('shows ssl controls on postgres configuration', function () { + loginAndSkipBoarding(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + // SSL toggle is present; mode selector appears after enabling SSL. + $page->assertSee('General') + ->assertSee('Config Postgres') + ->screenshot(filename: 'database-postgres-ssl-controls'); + + $page->script(<<<'JS' + (() => { + const toggle = document.querySelector('[id^="enableSsl"]'); + if (toggle) { + toggle.click(); + } + })() + JS); + $page->wait(2); + + $this->postgres->refresh(); + if ($this->postgres->enable_ssl) { + $page->assertSee('SSL Mode'); + } + + $page->screenshot(filename: 'database-postgres-ssl-after-toggle'); +}); + +it('shows redis configuration page', function () { + loginAndSkipBoarding(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->redis + ); + + $page = visit($url); + + $page->assertSee('Config Redis') + ->assertSee('General') + ->screenshot(filename: 'database-redis-configuration'); +}); + +it('lists databases on the environment resources page', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + + $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}"); + + $page->assertSee('Config Postgres') + ->assertSee('Config Redis') + ->screenshot(filename: 'environment-lists-databases'); +}); + +it('opens database environment variables and backups pages', function () { + loginAndSkipBoarding(); + + $base = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + visit("{$base}/environment-variables") + ->assertSee('Environment Variables') + ->screenshot(filename: 'database-environment-variables'); + + visit("{$base}/backups") + ->assertSee('Config Postgres') + ->screenshot(filename: 'database-backups'); +}); + +it('shows database danger zone', function () { + loginAndSkipBoarding(); + + $base = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + visit("{$base}/danger") + ->assertSee('Danger') + ->assertSee('Config Postgres') + ->screenshot(filename: 'database-danger-zone'); +}); diff --git a/tests/v4/Browser/DeploymentConfigReflectionTest.php b/tests/v4/Browser/DeploymentConfigReflectionTest.php new file mode 100644 index 000000000..c6f4b80ed --- /dev/null +++ b/tests/v4/Browser/DeploymentConfigReflectionTest.php @@ -0,0 +1,128 @@ +stack = seedBrowserResourceStack(); + $this->application = createBrowserApplication($this->stack, [ + 'uuid' => 'app-browser-deploy-config', + 'name' => 'Deploy Config App', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + ]); + $this->postgres = createBrowserPostgresql($this->stack, [ + 'uuid' => 'db-browser-deploy-config', + 'name' => 'Deploy Config Postgres', + 'custom_docker_run_options' => null, + ]); +}); + +it('reflects application UI docker run options in compose conversion used by deploy', function () { + loginAndSkipBoarding(); + + $hostname = 'deploy-cfg-'.Str::lower(Str::random(8)); + $options = "--hostname={$hostname} --cap-add=SYS_ADMIN --shm-size=128m"; + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + $page->fill('name', 'Deploy Config App Saved') + ->fill('portsExposes', '4000') + ->fill('customDockerRunOptions', $options) + ->screenshot(filename: 'deploy-config-app-before-save'); + + submitLivewireForm($page); + + $this->application->refresh(); + + expect($this->application->name)->toBe('Deploy Config App Saved') + ->and($this->application->ports_exposes)->toBe('4000') + ->and($this->application->custom_docker_run_options)->toBe($options); + + $composeOptions = convertDockerRunToCompose($this->application->custom_docker_run_options); + + expect(data_get($composeOptions, 'hostname'))->toBe($hostname) + ->and(data_get($composeOptions, 'cap_add'))->toContain('SYS_ADMIN') + ->and(data_get($composeOptions, 'shm_size'))->toBe('128m'); + + // Coolify configuration export includes ports; docker run options feed Start* compose merge. + $configuration = $this->application->getConfigurationAsArray(); + expect($configuration)->toBeArray() + ->and(data_get($configuration, 'domains.ports_exposes'))->toBe('4000'); + + visit($url) + ->assertValue('portsExposes', '4000') + ->assertValue('customDockerRunOptions', $options) + ->screenshot(filename: 'deploy-config-app-after-reload'); +}); + +it('reflects database UI custom docker run options in compose conversion', function () { + loginAndSkipBoarding(); + + $hostname = 'pg-deploy-'.Str::lower(Str::random(8)); + $options = "--hostname={$hostname} --shm-size=256m"; + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + $page->fill('customDockerRunOptions', $options); + submitLivewireForm($page); + + $this->postgres->refresh(); + expect($this->postgres->custom_docker_run_options)->toBe($options); + + $composeOptions = convertDockerRunToCompose($this->postgres->custom_docker_run_options); + expect(data_get($composeOptions, 'hostname'))->toBe($hostname) + ->and(data_get($composeOptions, 'shm_size'))->toBe('256m'); + + visit($url) + ->assertValue('customDockerRunOptions', $options) + ->screenshot(filename: 'deploy-config-database-docker-options'); +}); + +it('updates deploy-relevant fields used for configuration change detection', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $beforePorts = $this->application->ports_exposes; + $beforeOptions = $this->application->custom_docker_run_options; + + $page = visit($url); + $page->fill('portsExposes', '9090') + ->fill('customDockerRunOptions', '--hostname=hash-check-app'); + submitLivewireForm($page); + + $this->application->refresh(); + + expect($this->application->ports_exposes)->toBe('9090') + ->and($this->application->custom_docker_run_options)->toBe('--hostname=hash-check-app') + ->and($this->application->ports_exposes)->not->toBe($beforePorts) + ->and($this->application->custom_docker_run_options)->not->toBe($beforeOptions); + + $composeOptions = convertDockerRunToCompose($this->application->custom_docker_run_options); + expect(data_get($composeOptions, 'hostname'))->toBe('hash-check-app'); + + $page->screenshot(filename: 'deploy-config-hash-inputs'); +}); diff --git a/tests/v4/Browser/LivewireToggleEdgeCasesTest.php b/tests/v4/Browser/LivewireToggleEdgeCasesTest.php new file mode 100644 index 000000000..913acf94c --- /dev/null +++ b/tests/v4/Browser/LivewireToggleEdgeCasesTest.php @@ -0,0 +1,278 @@ +stack = seedBrowserResourceStack(); + $this->application = createBrowserApplication($this->stack, [ + 'uuid' => 'app-toggle-edge', + 'name' => 'Toggle Edge App', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + ]); + $this->postgres = createBrowserPostgresql($this->stack, [ + 'uuid' => 'db-toggle-ssl', + 'name' => 'Toggle SSL Postgres', + // SSL controls are only editable while status contains "exited". + 'status' => 'exited', + 'enable_ssl' => false, + 'ssl_mode' => 'prefer', + ]); +}); + +// --------------------------------------------------------------------------- +// Application: static / SPA / nginx +// --------------------------------------------------------------------------- + +it('shows site type control for nixpacks applications', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + + $page->assertSee('Site type') + ->assertSee('Dynamic') + ->assertDontSee('Custom Nginx configuration') + ->screenshot(filename: 'toggle-site-type-default-dynamic'); +}); + +it('enables static site and reveals custom nginx configuration', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + selectListboxOption($page, 'siteType', 'Static', 2); + + $page->assertSee('Custom Nginx configuration') + ->assertSee('Web server') + ->assertSee('nginx:alpine') + ->screenshot(filename: 'toggle-site-type-static'); + + $this->application->refresh(); + expect($this->application->settings->is_static)->toBeTrue() + ->and($this->application->settings->is_spa)->toBeFalse(); + + // Reload must keep static UI state. + visit($url) + ->assertSee('Custom Nginx configuration') + ->assertSee('Static') + ->screenshot(filename: 'toggle-site-type-static-reloaded'); +}); + +it('switches to spa and generates spa nginx try_files config', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + // Going straight to SPA (from dynamic) flips is_spa and regenerates nginx. + selectListboxOption($page, 'siteType', 'SPA (single-page application)', 2.5); + + $page->assertSee('Custom Nginx configuration') + ->screenshot(filename: 'toggle-site-type-spa-nginx'); + + $this->application->refresh(); + expect($this->application->settings->is_static)->toBeTrue() + ->and($this->application->settings->is_spa)->toBeTrue() + ->and((string) $this->application->custom_nginx_configuration) + ->toContain('try_files $uri $uri/ /index.html'); +}); + +it('switches from spa back to static and regenerates static nginx config', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + selectListboxOption($page, 'siteType', 'SPA (single-page application)', 2.5); + $this->application->refresh(); + expect($this->application->settings->is_spa)->toBeTrue(); + + selectListboxOption($page, 'siteType', 'Static', 2.5); + + $page->assertSee('Custom Nginx configuration') + ->screenshot(filename: 'toggle-site-type-static-nginx-from-spa'); + + $this->application->refresh(); + $nginx = (string) $this->application->custom_nginx_configuration; + expect($this->application->settings->is_static)->toBeTrue() + ->and($this->application->settings->is_spa)->toBeFalse() + ->and($nginx)->toContain('try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ =404') + ->and($nginx)->not->toContain('try_files $uri $uri/ /index.html'); +}); +it('returns to dynamic site type and hides nginx configuration section', function () { + loginAndSkipBoarding(); + + $url = applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + ); + + $page = visit($url); + selectListboxOption($page, 'siteType', 'Static', 2); + $page->assertSee('Custom Nginx configuration'); + + selectListboxOption($page, 'siteType', 'Dynamic', 2); + + $page->assertDontSee('Custom Nginx configuration') + ->screenshot(filename: 'toggle-site-type-back-to-dynamic'); + + $this->application->refresh(); + expect($this->application->settings->is_static)->toBeFalse() + ->and($this->application->settings->is_spa)->toBeFalse(); +}); + +// --------------------------------------------------------------------------- +// Database: SSL enable + every ssl_mode +// --------------------------------------------------------------------------- + +it('enables postgres ssl from the status listbox', function () { + loginAndSkipBoarding(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + $page->assertSee('SSL') + ->assertSee('SSL mode') + ->screenshot(filename: 'toggle-ssl-before-enable'); + + selectListboxOption($page, 'enableSsl', 'Enabled', 2); + + $this->postgres->refresh(); + expect((bool) $this->postgres->enable_ssl)->toBeTrue(); + + $page->assertSee('SSL mode') + ->screenshot(filename: 'toggle-ssl-enabled'); +}); + +it('cycles through every postgres ssl mode while ssl is enabled', function () { + loginAndSkipBoarding(); + + // Pre-enable so each mode change only hits sslMode listbox. + $this->postgres->enable_ssl = true; + $this->postgres->ssl_mode = 'prefer'; + $this->postgres->save(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + $page->assertSee('SSL mode'); + + $modes = [ + 'allow' => 'allow (insecure)', + 'prefer' => 'prefer (secure)', + 'require' => 'require (secure)', + 'verify-ca' => 'verify-ca (secure)', + 'verify-full' => 'verify-full (secure)', + ]; + + foreach ($modes as $value => $label) { + selectListboxOption($page, 'sslMode', $label, 2); + + $this->postgres->refresh(); + expect($this->postgres->ssl_mode)->toBe($value) + ->and((bool) $this->postgres->enable_ssl)->toBeTrue(); + + // Trigger label should update to the selected mode (parens break assertSee CSS). + $triggerText = $page->script(<<<'JS' + (() => (document.querySelector("#sslMode-trigger")?.innerText || "").replace(/\s+/g, " ").trim())() + JS); + expect($triggerText)->toBe($label); + + $page->screenshot(filename: "toggle-ssl-mode-{$value}"); + } + + // Final reload confirms last mode sticks. + $reloaded = visit($url); + $triggerText = $reloaded->script(<<<'JS' + (() => (document.querySelector("#sslMode-trigger")?.innerText || "").replace(/\s+/g, " ").trim())() + JS); + expect($triggerText)->toBe('verify-full (secure)'); + $reloaded->screenshot(filename: 'toggle-ssl-mode-verify-full-reloaded'); + + $this->postgres->refresh(); + expect($this->postgres->ssl_mode)->toBe('verify-full'); +}); +it('disables postgres ssl after modes have been set', function () { + loginAndSkipBoarding(); + + $this->postgres->enable_ssl = true; + $this->postgres->ssl_mode = 'require'; + $this->postgres->save(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + selectListboxOption($page, 'enableSsl', 'Disabled', 2); + + $this->postgres->refresh(); + expect((bool) $this->postgres->enable_ssl)->toBeFalse() + // Mode is retained for when SSL is re-enabled. + ->and($this->postgres->ssl_mode)->toBe('require'); + + $page->screenshot(filename: 'toggle-ssl-disabled'); +}); + +it('keeps ssl mode selector disabled while database is running', function () { + loginAndSkipBoarding(); + + $this->postgres->status = 'running:healthy'; + $this->postgres->enable_ssl = false; + $this->postgres->save(); + + $url = databaseConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->postgres + ); + + $page = visit($url); + + // Triggers should be disabled when not exited. + $enableDisabled = $page->script('(() => document.querySelector("#enableSsl-trigger")?.disabled === true)()'); + $modeDisabled = $page->script('(() => document.querySelector("#sslMode-trigger")?.disabled === true)()'); + + expect($enableDisabled)->toBeTrue() + ->and($modeDisabled)->toBeTrue(); + + $page->screenshot(filename: 'toggle-ssl-disabled-while-running'); +}); diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php index 716acd20a..7a6d6f2b7 100644 --- a/tests/v4/Browser/LoginTest.php +++ b/tests/v4/Browser/LoginTest.php @@ -8,45 +8,76 @@ use Illuminate\Support\Facades\Hash; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]); + // `id` is not mass-assignable; forceCreate so InstanceSettings::get() (id 0) works. + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_sponsorship_popup_enabled' => false, + 'is_registration_enabled' => true, + ]); }); -it('shows registration page when no users exist', function () { +it('redirects to root registration when no users exist', function () { $page = visit('/login'); - $page->assertSee('Root User Setup') - ->assertSee('Create Account') - ->screenshot(); + $page->assertPathIs('/register') + ->assertSee('Create the root account for this instance.') + ->assertSee('Full instance access') + ->assertSee('Create account') + ->screenshot(filename: 'login-no-users-redirects-to-register'); +}); + +it('shows the login form when a user exists', function () { + createRootUser(); + + $page = visit('/login'); + + $page->assertPathIs('/login') + ->assertSee('Sign in to manage your applications and infrastructure.') + ->assertSee('Email') + ->assertSee('Password') + ->assertSee('Login') + ->screenshot(filename: 'login-form'); }); it('can login with valid credentials', function () { - User::factory()->create([ - 'id' => 0, - 'email' => 'test@example.com', - 'password' => Hash::make('password'), - ]); + createRootUser(); $page = visit('/login'); - $page->fill('email', 'test@example.com') + $page->screenshot(filename: 'login-valid-before') + ->fill('email', 'test@example.com') ->fill('password', 'password') + ->screenshot(filename: 'login-valid-filled') ->click('Login') ->assertSee('Welcome to Coolify') - ->screenshot(); + ->assertSee('Connect your first server and start deploying in minutes.') + ->assertSee('Continue') + ->assertSee('Skip setup') + ->screenshot(filename: 'login-valid-onboarding'); }); it('fails login with invalid credentials', function () { - User::factory()->create([ - 'id' => 0, - 'email' => 'test@example.com', - 'password' => Hash::make('password'), - ]); + createRootUser(); $page = visit('/login'); $page->fill('email', 'random@email.com') ->fill('password', 'wrongpassword123') ->click('Login') - ->assertSee('These credentials do not match our records') - ->screenshot(); + ->assertPathIs('/login') + ->assertSee('These credentials do not match our records.') + ->screenshot(filename: 'login-invalid-credentials'); }); + +/** + * Create the root user (id 0) with known credentials for browser login tests. + */ +function createRootUser(): User +{ + return User::forceCreate([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); +} diff --git a/tests/v4/Browser/ProjectAndServerTest.php b/tests/v4/Browser/ProjectAndServerTest.php new file mode 100644 index 000000000..f29357805 --- /dev/null +++ b/tests/v4/Browser/ProjectAndServerTest.php @@ -0,0 +1,137 @@ +stack = seedBrowserResourceStack([ + 'projectUuid' => 'project-core-1', + 'projectName' => 'Core Project Alpha', + 'projectDescription' => 'Primary browser project', + 'serverName' => 'localhost', + 'serverDescription' => 'Local testing host', + ]); + + Project::create([ + 'uuid' => 'project-core-2', + 'name' => 'Core Project Beta', + 'description' => 'Secondary browser project', + 'team_id' => 0, + ]); + + Server::create([ + 'uuid' => 'server-core-2', + 'name' => 'remote-web', + 'description' => 'Remote web server', + 'ip' => '10.0.0.50', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + $this->application = createBrowserApplication($this->stack, [ + 'uuid' => 'app-core-env', + 'name' => 'Env Listed App', + ]); +}); + +it('shows dashboard with projects and servers after login', function () { + $page = loginAndSkipBoarding(); + + $page->assertSee('Dashboard') + ->assertSee('Core Project Alpha') + ->assertSee('Core Project Beta') + ->assertSee('localhost') + ->assertSee('remote-web') + ->screenshot(filename: 'core-dashboard'); +}); + +it('navigates to project environments page', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $page = visit("/project/{$project->uuid}"); + + $page->assertSee('Core Project Alpha') + ->assertSee('environment') + ->screenshot(filename: 'core-project-environments'); +}); + +it('navigates to environment resources listing applications', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + + $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}"); + + $page->assertSee('Env Listed App') + ->screenshot(filename: 'core-environment-resources'); +}); + +it('shows server configuration page for owner', function () { + loginAndSkipBoarding(); + + $server = $this->stack['server']; + $page = visit("/server/{$server->uuid}"); + + $page->assertSee('localhost') + ->assertSee('General') + ->assertSee('Configuration') + ->assertSee('Save') + ->screenshot(filename: 'core-server-configuration'); +}); + +it('shows team general and members pages', function () { + loginAndSkipBoarding(); + + visit('/team') + ->assertSee('General') + ->assertSee('Danger Zone') + ->screenshot(filename: 'core-team-general'); + + visit('/team/members') + ->assertSee('Members') + ->assertSee('Invite a member') + ->screenshot(filename: 'core-team-members'); +}); + +it('shows projects index', function () { + loginAndSkipBoarding(); + + visit('/projects') + ->assertSee('Core Project Alpha') + ->assertSee('Core Project Beta') + ->screenshot(filename: 'core-projects-index'); +}); + +it('shows servers index', function () { + loginAndSkipBoarding(); + + visit('/servers') + ->assertSee('localhost') + ->assertSee('remote-web') + ->screenshot(filename: 'core-servers-index'); +}); + +it('protects core routes when unauthenticated', function () { + visit('/dashboard')->assertPathIs('/login'); + visit('/projects')->assertPathIs('/login'); + visit('/servers')->assertPathIs('/login'); + visit('/team')->assertPathIs('/login'); + + $project = $this->stack['project']; + visit("/project/{$project->uuid}")->assertPathIs('/login'); + + $server = $this->stack['server']; + visit("/server/{$server->uuid}")->assertPathIs('/login') + ->screenshot(filename: 'core-unauthenticated-redirect'); +}); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index 42a871257..62025924a 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -1,21 +1,21 @@ 0, 'is_sponsorship_popup_enabled' => false]); + seedBrowserInstanceSettings(); }); it('shows registration page when no users exist', function () { $page = visit('/register'); - $page->assertSee('Root User Setup') - ->assertSee('Create Account') - ->screenshot(); + $page->assertSee('Create the root account for this instance.') + ->assertSee('Full instance access') + ->assertSee('Create account') + ->screenshot(filename: 'registration-root-setup'); }); it('can register a new root user', function () { @@ -25,9 +25,9 @@ it('can register a new root user', function () { ->fill('email', 'root@example.com') ->fill('password', 'Password1!@') ->fill('password_confirmation', 'Password1!@') - ->click('Create Account') + ->click('Create account') ->assertPathIs('/onboarding') - ->screenshot(); + ->screenshot(filename: 'registration-success-onboarding'); expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); }); @@ -39,9 +39,9 @@ it('fails registration with mismatched passwords', function () { ->fill('email', 'root@example.com') ->fill('password', 'Password1!@') ->fill('password_confirmation', 'DifferentPass1!@') - ->click('Create Account') + ->click('Create account') ->assertSee('password') - ->screenshot(); + ->screenshot(filename: 'registration-password-mismatch'); }); it('fails registration with weak password', function () { @@ -51,17 +51,17 @@ it('fails registration with weak password', function () { ->fill('email', 'root@example.com') ->fill('password', 'short') ->fill('password_confirmation', 'short') - ->click('Create Account') + ->click('Create account') ->assertSee('password') - ->screenshot(); + ->screenshot(filename: 'registration-weak-password'); }); it('shows login link when a user already exists', function () { - User::factory()->create(['id' => 0]); + createBrowserRootUser(); $page = visit('/register'); - $page->assertSee('Already registered?') - ->assertDontSee('Root User Setup') - ->screenshot(); + $page->assertSee('Already have an account?') + ->assertDontSee('Full instance access') + ->screenshot(filename: 'registration-existing-user'); }); diff --git a/tests/v4/Browser/ResourceSettingsPersistenceTest.php b/tests/v4/Browser/ResourceSettingsPersistenceTest.php index c9d698efc..0f1e2a6df 100644 --- a/tests/v4/Browser/ResourceSettingsPersistenceTest.php +++ b/tests/v4/Browser/ResourceSettingsPersistenceTest.php @@ -1,30 +1,16 @@ 0, 'is_sponsorship_popup_enabled' => false]); - - $this->user = User::factory()->create([ - 'id' => 0, - 'name' => 'Root User', - 'email' => 'test@example.com', - 'password' => Hash::make('password'), + $this->stack = seedBrowserResourceStack([ + 'projectUuid' => 'project-resource-persistence', + 'projectName' => 'Resource Persistence', + 'projectDescription' => 'Browser persistence tests', ]); DiscordNotificationSettings::where('team_id', 0)->update([ @@ -32,149 +18,84 @@ beforeEach(function () { 'discord_webhook_url' => 'https://discord.com/test', ]); - PrivateKey::create([ - 'id' => 1, - 'uuid' => 'ssh-test', - 'team_id' => 0, - 'name' => 'Test Key', - 'description' => 'Test SSH key', - 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----', - ]); + $this->user = $this->stack['user']; + $this->server = $this->stack['server']; + $this->project = $this->stack['project']; + $this->environment = $this->stack['environment']; + $this->destination = $this->stack['destination']; - $this->server = Server::create([ - 'id' => 0, - 'uuid' => 'localhost', - 'name' => 'localhost', - 'description' => 'Test docker container in development', - 'ip' => 'coolify-testing-host', - 'team_id' => 0, - 'private_key_id' => 1, - 'proxy' => [ - 'type' => ProxyTypes::TRAEFIK->value, - 'status' => ProxyStatus::EXITED->value, - ], - ]); - - $this->project = Project::create([ - 'uuid' => 'project-resource-persistence', - 'name' => 'Resource Persistence', - 'description' => 'Browser persistence tests', - 'team_id' => 0, - ]); - - $this->environment = $this->project->environments()->first(); - - StandaloneDocker::withoutEvents(function () { - $this->destination = StandaloneDocker::firstOrCreate( - ['server_id' => $this->server->id, 'network' => 'coolify'], - ['uuid' => 'docker-destination-1', 'name' => 'docker-destination-1'] - ); - }); - - $this->application = Application::factory()->create([ + $this->application = createBrowserApplication($this->stack, [ 'uuid' => 'app-resource-persistence', 'name' => 'App Before Browser Save', - 'git_repository' => 'https://github.com/coollabsio/coolify.git', - 'git_branch' => 'main', - 'build_pack' => 'nixpacks', - 'ports_exposes' => '3000', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), ]); - $this->database = StandalonePostgresql::create([ + $this->database = createBrowserPostgresql($this->stack, [ 'uuid' => 'db-resource-persistence', 'name' => 'Database Before Browser Save', 'description' => 'Initial database description', - 'postgres_user' => 'postgres', - 'postgres_password' => 'postgres-password', - 'postgres_db' => 'postgres', - 'image' => 'postgres:15-alpine', - 'status' => 'exited', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), ]); }); -it('saves application name and enables static site with nginx config', function () { +it('saves application name and custom docker run options from general form', function () { loginAndSkipBoarding(); $updatedName = 'App Saved '.(string) new Cuid2; $applicationRoute = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/application/{$this->application->uuid}"; $page = visit($applicationRoute); - $page->screenshot(); + $page->screenshot(filename: 'resource-app-before-save'); $page->assertSee('General') - ->assertDontSee('Custom Nginx Configuration') ->fill('name', $updatedName) - ->fill('customDockerRunOptions', '--read-only'); + ->fill('description', 'Saved by browser persistence test') + ->fill('customDockerRunOptions', '--hostname=persist-app --read-only'); submitLivewireForm($page); - $page->click('[id^="isStatic"]') - ->wait(2) - ->screenshot(); - $page->assertSourceHas('Custom Nginx Configuration') - ->assertSee('Is it a SPA (Single Page Application)?') - ->assertValue('name', $updatedName); + $page->assertValue('name', $updatedName) + ->screenshot(filename: 'resource-app-after-save'); $this->application->refresh(); expect($this->application->name)->toBe($updatedName) - ->and($this->application->custom_docker_run_options)->toBe('--read-only') - ->and($this->application->settings->is_static)->toBeTrue(); + ->and($this->application->description)->toBe('Saved by browser persistence test') + ->and($this->application->custom_docker_run_options)->toBe('--hostname=persist-app --read-only'); $reloadedPage = visit($applicationRoute); - $reloadedPage->screenshot(); - $reloadedPage->assertValue('name', $updatedName) - ->assertSourceHas('Custom Nginx Configuration') - ->assertSourceHas('Is it a SPA (Single Page Application)?'); + ->assertValue('customDockerRunOptions', '--hostname=persist-app --read-only') + ->screenshot(filename: 'resource-app-reloaded'); }); -it('saves database name and enables ssl with mode selector', function () { +it('saves database name description and docker options from general form', function () { loginAndSkipBoarding(); $updatedDatabaseName = 'Database Saved '.(string) new Cuid2; $databaseRoute = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}"; $page = visit($databaseRoute); - $page->screenshot(); + $page->screenshot(filename: 'resource-db-before-save'); $page->assertSee('General') - ->assertDontSee('SSL Mode') ->fill('name', $updatedDatabaseName) - ->fill('description', 'Updated by browser test'); + ->fill('description', 'Updated by browser test') + ->fill('customDockerRunOptions', '--hostname=persist-db --shm-size=128m'); submitLivewireForm($page); - $page->click('[id^="enableSsl"]'); - $page->assertSee('SSL Mode') - ->assertValue('name', $updatedDatabaseName); - $page->screenshot(); + $page->assertValue('name', $updatedDatabaseName) + ->screenshot(filename: 'resource-db-after-save'); $this->database->refresh(); expect($this->database->name)->toBe($updatedDatabaseName) ->and($this->database->description)->toBe('Updated by browser test') - ->and($this->database->enable_ssl)->toBeTruthy(); + ->and($this->database->custom_docker_run_options)->toBe('--hostname=persist-db --shm-size=128m'); + + $compose = convertDockerRunToCompose($this->database->custom_docker_run_options); + expect(data_get($compose, 'hostname'))->toBe('persist-db') + ->and(data_get($compose, 'shm_size'))->toBe('128m'); $reloadedPage = visit($databaseRoute); - $reloadedPage->screenshot(); - $reloadedPage->assertValue('name', $updatedDatabaseName) - ->assertSee('SSL Mode'); + ->assertValue('description', 'Updated by browser test') + ->screenshot(filename: 'resource-db-reloaded'); }); - -function submitLivewireForm($page): void -{ - $page->script("document.querySelector('form[wire\\\\:submit=\"submit\"]')?.requestSubmit()"); - $page->wait(1); -} diff --git a/tests/v4/Browser/ServiceConfigurationTest.php b/tests/v4/Browser/ServiceConfigurationTest.php new file mode 100644 index 000000000..becad8ef6 --- /dev/null +++ b/tests/v4/Browser/ServiceConfigurationTest.php @@ -0,0 +1,136 @@ +stack = seedBrowserResourceStack(); + $created = createBrowserService($this->stack, [ + 'uuid' => 'svc-browser-config', + 'name' => 'Config Service', + 'description' => 'Compose stack for browser tests', + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: nginx:alpine + ports: + - '80' + api: + image: httpd:alpine +YAML, + ]); + $this->service = $created['service']; + $this->serviceApplication = $created['serviceApplication']; +}); + +it('shows service configuration with compose resources', function () { + loginAndSkipBoarding(); + + $url = serviceConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->service + ); + + $page = visit($url); + + $page->assertSee('Config Service') + ->assertSee('General') + ->assertSee('Environment Variables') + ->assertSee('Compose resources') + ->assertSee('web') + ->screenshot(filename: 'service-configuration-overview'); +}); + +it('saves service name and description', function () { + loginAndSkipBoarding(); + + $updatedName = 'Service UI '.(string) new Cuid2; + $url = serviceConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->service + ); + + $page = visit($url); + $page->fill('name', $updatedName) + ->fill('description', 'Updated service description') + ->screenshot(filename: 'service-before-save'); + + // StackForm Livewire component owns name/description save. + submitLivewireForm($page, 'submit'); + $page->wait(2); + + $this->service->refresh(); + expect($this->service->name)->toBe($updatedName) + ->and($this->service->description)->toBe('Updated service description'); + + visit($url) + ->assertValue('name', $updatedName) + ->screenshot(filename: 'service-after-reload'); +}); + +it('opens service environment variables domains and storages pages', function () { + loginAndSkipBoarding(); + + $base = serviceConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->service + ); + + visit("{$base}/environment-variables") + ->assertSee('Environment Variables') + ->screenshot(filename: 'service-environment-variables'); + + visit("{$base}/domains") + ->assertSee('Config Service') + ->screenshot(filename: 'service-domains'); + + visit("{$base}/storages") + ->assertSee('Config Service') + ->screenshot(filename: 'service-storages'); +}); + +it('opens compose stack application general settings', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + $service = $this->service; + $stackUuid = $this->serviceApplication->uuid; + + $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}/{$stackUuid}"); + + $page->assertSee('web') + ->assertSee('General') + ->screenshot(filename: 'service-stack-application-general'); +}); + +it('lists the service on the environment resources page', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + + visit("/project/{$project->uuid}/environment/{$environment->uuid}") + ->assertSee('Config Service') + ->screenshot(filename: 'environment-lists-service'); +}); + +it('shows service danger zone', function () { + loginAndSkipBoarding(); + + $base = serviceConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->service + ); + + visit("{$base}/danger") + ->assertSee('Danger') + ->assertSee('Config Service') + ->screenshot(filename: 'service-danger-zone'); +});