test: migrate to SQLite :memory: and add Pest browser testing (#8364)

This commit is contained in:
Andras Bacsai 2026-02-16 14:41:54 +01:00 committed by GitHub
commit 766355b9ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4034 additions and 96 deletions

View file

@ -23,19 +23,28 @@ ## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
## Test Directory Structure
### Creating Tests
- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete)
- `tests/v4/Feature/` — New feature tests (SQLite :memory: database)
- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright)
- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete)
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations.
### Test Organization
Do NOT remove tests without approval.
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
## Running Tests
### Basic Test Structure
- All v4 tests: `php artisan test --compact tests/v4/`
- Browser tests: `php artisan test --compact tests/v4/Browser/`
- Feature tests: `php artisan test --compact tests/v4/Feature/`
- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php`
- Filter: `php artisan test --compact --filter=testName`
- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed`
- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug`
## Basic Test Structure
<code-snippet name="Basic Pest Test Example" lang="php">
@ -45,24 +54,10 @@ ### Basic Test Structure
</code-snippet>
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<code-snippet name="Pest Response Assertion" lang="php">
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
</code-snippet>
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
@ -75,7 +70,7 @@ ## Mocking
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
Use datasets for repetitive tests:
<code-snippet name="Pest Dataset Example" lang="php">
@ -88,73 +83,94 @@ ## Datasets
</code-snippet>
## Pest 4 Features
## Browser Testing (Pest Browser Plugin + Playwright)
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically.
### Browser Test Example
### Key Rules
Browser tests run in real browsers for full integration testing:
1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory:
2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it
3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user
4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API
5. **Place tests in `tests/v4/Browser/`**
6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
### Browser Test Template
<code-snippet name="Pest Browser Test Example" lang="php">
<code-snippet name="Coolify Browser Test Template" lang="php">
<?php
it('may reset the password', function () {
Notification::fake();
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
$this->actingAs(User::factory()->create());
uses(RefreshDatabase::class);
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('can visit the page', function () {
$page = visit('/login');
$page->assertSee('Login');
});
</code-snippet>
### Smoke Testing
### Browser Test with Form Interaction
Quickly validate multiple pages have no JavaScript errors:
<code-snippet name="Browser Test Form Example" lang="php">
it('fails login with invalid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
$page = visit('/login');
$page->fill('email', 'random@email.com')
->fill('password', 'wrongpassword123')
->click('Login')
->assertSee('These credentials do not match our records');
});
</code-snippet>
### Visual Regression Testing
### Browser API Reference
Capture and compare screenshots to detect visual changes.
| Method | Purpose |
|--------|---------|
| `visit('/path')` | Navigate to a page |
| `->fill('field', 'value')` | Fill an input by name |
| `->click('Button Text')` | Click a button/link by text |
| `->assertSee('text')` | Assert visible text |
| `->assertDontSee('text')` | Assert text is not visible |
| `->assertPathIs('/path')` | Assert current URL path |
| `->assertSeeIn('.selector', 'text')` | Assert text in element |
| `->screenshot()` | Capture screenshot |
| `->debug()` | Pause test, keep browser open |
| `->wait(seconds)` | Wait N seconds |
### Test Sharding
### Debugging
Split tests across parallel processes for faster CI runs.
- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure
- `->debug()` pauses and keeps browser open (press Enter to continue)
- `->screenshot()` captures state at any point
- `--headed` flag shows browser, `--debug` pauses on failure
### Architecture Testing
## SQLite Testing Setup
Pest 4 includes architecture testing (from Pest 3):
v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`.
### Regenerating the Schema
When migrations change, regenerate from the running PostgreSQL database:
```bash
docker exec coolify php artisan schema:generate-testing
```
## Architecture Testing
<code-snippet name="Architecture Test Example" lang="php">
@ -171,4 +187,7 @@ ## Common Pitfalls
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it**
- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty**
- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard**

15
.env.testing Normal file
View file

@ -0,0 +1,15 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
DB_CONNECTION=testing
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
TELESCOPE_ENABLED=false
REDIS_HOST=127.0.0.1
SELF_HOSTED=true

2
.gitignore vendored
View file

@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
/.workspaces
tests/Browser/Screenshots
tests/v4/Browser/Screenshots

View file

@ -0,0 +1,222 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateTestingSchema extends Command
{
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
private array $typeMap = [
'/\bbigint\b/' => 'INTEGER',
'/\binteger\b/' => 'INTEGER',
'/\bsmallint\b/' => 'INTEGER',
'/\bboolean\b/' => 'INTEGER',
'/character varying\(\d+\)/' => 'TEXT',
'/timestamp\(\d+\) without time zone/' => 'TEXT',
'/timestamp\(\d+\) with time zone/' => 'TEXT',
'/\bjsonb\b/' => 'TEXT',
'/\bjson\b/' => 'TEXT',
'/\buuid\b/' => 'TEXT',
'/double precision/' => 'REAL',
'/numeric\(\d+,\d+\)/' => 'REAL',
'/\bdate\b/' => 'TEXT',
];
private array $castRemovals = [
'::character varying',
'::text',
'::integer',
'::boolean',
'::timestamp without time zone',
'::timestamp with time zone',
'::numeric',
];
public function handle(): int
{
$connection = $this->option('connection');
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
$this->error("Connection '{$connection}' is not PostgreSQL.");
return self::FAILURE;
}
$this->info('Reading schema from PostgreSQL...');
$tables = $this->getTables($connection);
$lastMigration = DB::connection($connection)
->table('migrations')
->orderByDesc('id')
->value('migration');
$output = [];
$output[] = '-- Generated by: php artisan schema:generate-testing';
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
$output[] = '';
foreach ($tables as $table) {
$columns = $this->getColumns($connection, $table);
$output[] = $this->generateCreateTable($table, $columns);
}
$indexes = $this->getIndexes($connection, $tables);
foreach ($indexes as $index) {
$output[] = $index;
}
$output[] = '';
$output[] = '-- Migration records';
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
foreach ($migrations as $m) {
$migration = str_replace("'", "''", $m->migration);
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
}
$path = database_path('schema/testing-schema.sql');
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, implode("\n", $output)."\n");
$this->info("Schema written to {$path}");
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
return self::SUCCESS;
}
private function getTables(string $connection): array
{
return collect(DB::connection($connection)->select(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
))->pluck('tablename')->toArray();
}
private function getColumns(string $connection, string $table): array
{
return DB::connection($connection)->select(
"SELECT column_name, data_type, character_maximum_length, column_default,
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ?
ORDER BY ordinal_position",
[$table]
);
}
private function generateCreateTable(string $table, array $columns): string
{
$lines = [];
foreach ($columns as $col) {
$lines[] = ' '.$this->generateColumnDef($table, $col);
}
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
}
private function generateColumnDef(string $table, object $col): string
{
$name = $col->column_name;
$sqliteType = $this->convertType($col);
// Auto-increment primary key for id columns
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
}
$parts = ["\"{$name}\"", $sqliteType];
// Default value
$default = $col->column_default;
if ($default !== null && ! str_contains($default, 'nextval')) {
$default = $this->cleanDefault($default);
$parts[] = "DEFAULT {$default}";
}
// NOT NULL
if ($col->is_nullable === 'NO') {
$parts[] = 'NOT NULL';
}
return implode(' ', $parts);
}
private function convertType(object $col): string
{
$pgType = $col->data_type;
return match (true) {
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
$pgType === 'boolean' => 'INTEGER',
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
str_contains($pgType, 'timestamp') => 'TEXT',
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
$pgType === 'uuid' => 'TEXT',
$pgType === 'double precision' => 'REAL',
$pgType === 'numeric' => 'REAL',
$pgType === 'date' => 'TEXT',
default => 'TEXT',
};
}
private function cleanDefault(string $default): string
{
foreach ($this->castRemovals as $cast) {
$default = str_replace($cast, '', $default);
}
// Remove array type casts like ::text[]
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
return $default;
}
private function getIndexes(string $connection, array $tables): array
{
$results = [];
$indexes = DB::connection($connection)->select(
"SELECT indexname, tablename, indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname"
);
foreach ($indexes as $idx) {
$def = $idx->indexdef;
// Skip primary key indexes
if (str_contains($def, '_pkey')) {
continue;
}
// Skip PG-specific indexes (GIN, GIST, expression indexes)
if (preg_match('/USING (gin|gist)/i', $def)) {
continue;
}
if (str_contains($def, '->>') || str_contains($def, '::')) {
continue;
}
// Convert to SQLite-compatible CREATE INDEX
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
// Extract columns from the index definition
if (preg_match('/\((.+)\)$/', $def, $m)) {
$cols = $m[1];
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
}
}
return $results;
}
}

View file

@ -167,6 +167,10 @@ function currentTeam()
function showBoarding(): bool
{
if (isDev()) {
return false;
}
if (Auth::user()?->isMember()) {
return false;
}

View file

@ -69,6 +69,7 @@
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.3",
"pestphp/pest": "^4.3.2",
"pestphp/pest-plugin-browser": "^4.2",
"phpstan/phpstan": "^2.1.38",
"rector/rector": "^2.3.5",
"serversideup/spin": "^3.1.1",

1565
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -54,18 +54,10 @@
],
'testing' => [
'driver' => 'pgsql',
'url' => env('DATABASE_TEST_URL'),
'host' => env('DB_TEST_HOST', 'postgres'),
'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'coolify_test'),
'username' => env('DB_TEST_USERNAME', 'coolify'),
'password' => env('DB_TEST_PASSWORD', 'password'),
'charset' => 'utf8',
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'foreign_key_constraints' => true,
],
],

View file

@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration
{
public function up()
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
try {
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb');
DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)');
@ -18,6 +22,10 @@ public function up()
public function down()
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
try {
DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid');
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json');

View file

@ -22,6 +22,10 @@
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
foreach ($this->indexes as [$table, $columns, $indexName]) {
if (! $this->indexExists($indexName)) {
$columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns));
@ -32,6 +36,10 @@ public function up(): void
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
foreach ($this->indexes as [, , $indexName]) {
DB::statement("DROP INDEX IF EXISTS \"{$indexName}\"");
}

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,8 @@ services:
volumes:
- .:/var/www/html/:cached
- dev_backups_data:/var/www/html/storage/app/backups
networks:
- coolify
postgres:
pull_policy: always
ports:

47
package-lock.json generated
View file

@ -10,7 +10,8 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1"
"ioredis": "5.6.1",
"playwright": "^1.58.2"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
@ -2338,6 +2339,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View file

@ -24,6 +24,7 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1"
"ioredis": "5.6.1",
"playwright": "^1.58.2"
}
}
}

View file

@ -7,17 +7,20 @@
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="v4">
<directory suffix="Test.php">./tests/v4</directory>
</testsuite>
</testsuites>
<coverage/>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="testing"/>
<env name="DB_TEST_DATABASE" value="coolify"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="CACHE_DRIVER" value="array" force="true"/>
<env name="DB_CONNECTION" value="testing" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
<source>

View file

@ -1,7 +1,9 @@
<?php
function getOldOrLocal($key, $localValue)
{
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
if (! function_exists('getOldOrLocal')) {
function getOldOrLocal($key, $localValue)
{
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
}
}
$name = getOldOrLocal('name', 'test3 normal user');

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -13,7 +13,7 @@
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
uses(Tests\TestCase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,162 @@
<?php
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
$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,
],
]);
Server::create([
'uuid' => 'production-1',
'name' => 'production-web',
'description' => 'Production web server cluster',
'ip' => '10.0.0.1',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
Server::create([
'uuid' => 'staging-1',
'name' => 'staging-server',
'description' => 'Staging environment server',
'ip' => '10.0.0.2',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
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',
'description' => 'Backend services for production',
'team_id' => 0,
]);
Project::create([
'uuid' => 'project-3',
'name' => 'Staging Environment',
'description' => 'Staging and QA testing',
'team_id' => 0,
]);
});
function loginAndSkipOnboarding(): mixed
{
return visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->click('Skip Setup');
}
it('redirects to login when not authenticated', function () {
$page = visit('/');
$page->assertPathIs('/login')
->screenshot();
});
it('shows onboarding after first login', function () {
$page = visit('/login');
$page->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Welcome to Coolify')
->assertSee("Let's go!")
->assertSee('Skip Setup')
->screenshot();
});
it('shows dashboard after skipping onboarding', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Dashboard')
->assertSee('Your self-hosted infrastructure.')
->screenshot();
});
it('shows all projects on dashboard', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Projects')
->assertSee('My first project')
->assertSee('This is a test project in development')
->assertSee('Production API')
->assertSee('Backend services for production')
->assertSee('Staging Environment')
->assertSee('Staging and QA testing')
->screenshot();
});
it('shows servers on dashboard', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Servers')
->assertSee('localhost')
->assertSee('This is a test docker container in development mode')
->assertSee('production-web')
->assertSee('Production web server cluster')
->assertSee('staging-server')
->assertSee('Staging environment server')
->screenshot();
});

View file

@ -0,0 +1,52 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('shows registration page when no users exist', function () {
$page = visit('/login');
$page->assertSee('Root User Setup')
->assertSee('Create Account')
->screenshot();
});
it('can login with valid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$page = visit('/login');
$page->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Welcome to Coolify')
->screenshot();
});
it('fails login with invalid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$page = visit('/login');
$page->fill('email', 'random@email.com')
->fill('password', 'wrongpassword123')
->click('Login')
->assertSee('These credentials do not match our records')
->screenshot();
});

View file

@ -0,0 +1,67 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('shows registration page when no users exist', function () {
$page = visit('/register');
$page->assertSee('Root User Setup')
->assertSee('Create Account')
->screenshot();
});
it('can register a new root user', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'Password1!@')
->fill('password_confirmation', 'Password1!@')
->click('Create Account')
->assertPathIs('/onboarding')
->screenshot();
expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
});
it('fails registration with mismatched passwords', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'Password1!@')
->fill('password_confirmation', 'DifferentPass1!@')
->click('Create Account')
->assertSee('password')
->screenshot();
});
it('fails registration with weak password', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'short')
->fill('password_confirmation', 'short')
->click('Create Account')
->assertSee('password')
->screenshot();
});
it('shows login link when a user already exists', function () {
User::factory()->create(['id' => 0]);
$page = visit('/register');
$page->assertSee('Already registered?')
->assertDontSee('Root User Setup')
->screenshot();
});

View file

@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
it('uses sqlite for testing', function () {
expect(DB::connection()->getDriverName())->toBe('sqlite');
});
it('runs migrations successfully', function () {
expect(Schema::hasTable('users'))->toBeTrue();
expect(Schema::hasTable('teams'))->toBeTrue();
expect(Schema::hasTable('servers'))->toBeTrue();
expect(Schema::hasTable('applications'))->toBeTrue();
});