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.
This commit is contained in:
Andras Bacsai
2026-08-04 11:43:45 +02:00
parent ff0590137d
commit 07933640f2
14 changed files with 1508 additions and 270 deletions
+2
View File
@@ -25,6 +25,8 @@
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- Host-side Pest browser runs cannot resolve docker DNS (coolify-realtime). -->
<env name="BROADCAST_DRIVER" value="null" force="true"/>
<!-- The v5 bootstrap endpoint refuses to queue without a Flux URL; tests
that exercise the unconfigured path blank it via Config::set. -->
<env name="COOLIFY_COOLD_FLUX_URL" value="http://flux.testing:6443" force="true"/>
+21 -2
View File
@@ -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;
}
/*
+338
View File
@@ -0,0 +1,338 @@
<?php
/*
|--------------------------------------------------------------------------
| Shared Browser Test Helpers
|--------------------------------------------------------------------------
|
| Helpers for Pest browser tests under tests/v4/Browser and tests/v5/Browser.
| Loaded from tests/Pest.php.
|
*/
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* InstanceSettings.id is not fillable; always forceCreate id 0 for browser tests.
*/
function seedBrowserInstanceSettings(array $attributes = []): InstanceSettings
{
return InstanceSettings::forceCreate(array_merge([
'id' => 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(<<<JS
(() => {
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(<<<JS
(() => {
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(<<<JS
(() => {
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);
}
@@ -0,0 +1,153 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
+36 -69
View File
@@ -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();
});
+16 -52
View File
@@ -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 () {
@@ -0,0 +1,164 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
@@ -0,0 +1,128 @@
<?php
/**
* Browser + domain assertions that configuration changes made in the UI
* are persisted and reflected in generated Docker/Coolify deployment config.
*
* Full remote deploy against Docker is out of scope for pure browser tests;
* these tests prove the deploy pipeline inputs update correctly after UI saves.
*/
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
@@ -0,0 +1,278 @@
<?php
/**
* Browser coverage for Livewire toggle edge cases that previously flaked:
* - Application site type: Dynamic Static SPA (nginx config generation)
* - PostgreSQL SSL enable/disable and every ssl_mode option
*/
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
+50 -19
View File
@@ -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'),
]);
}
+137
View File
@@ -0,0 +1,137 @@
<?php
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
+15 -15
View File
@@ -1,21 +1,21 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 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');
});
@@ -1,30 +1,16 @@
<?php
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\DiscordNotificationSettings;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Visus\Cuid2\Cuid2;
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'),
$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);
}
@@ -0,0 +1,136 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});