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');
+});