diff --git a/AGENTS.md b/AGENTS.md index a96bbf59a..2fb5be7f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,23 @@ function loginAsRoot(): mixed - **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources. - **Proxy** — Traefik reverse proxy managed per server. +### Instance sentinels (`id = 0`) + +Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id. + +| Record | Model / lookup | Meaning | +|---|---|---| +| Root team | `Team::find(0)`, `team_id === 0` | Instance / root team. Cloud billing and many skip-checks exempt `team_id === 0`. | +| Localhost server | `Server::find(0)` / `findOrFail(0)` | The machine running Coolify. Upgrades, instance backups, and docker inspect target this server. | +| Instance settings | `InstanceSettings` with `id = 0` | Singleton settings row. Tests must seed `InstanceSettings::create(['id' => 0])` (or `forceCreate`). | +| Instance Postgres | `StandalonePostgresql` `id = 0`, name `coolify-db` | Coolify’s own database. UI treats `database_id === 0` as the instance DB (e.g. hide delete on backup screens). | +| Local docker dest | `StandaloneDocker` `id = 0` | Destination on the localhost server (`destination_id = 0`). | +| Root user / default GitHub App | seeders | First-install defaults. | + +**Do not assign `id = 0` to new or non-instance rows.** In particular, `ScheduledDatabaseBackup` and `ScheduledTask` are ordinary schedules. Legacy installs may still have a `coolify-db` backup at `id = 0`; resolve that backup via the `coolify-db` relation / uuid, not `ScheduledDatabaseBackup::find(0)`. + +`0` is a PHP/Eloquent landmine (`empty(0)` is true; keyset pagination `where('id', '>', $cursor)` starting at `0` skips the row). Queries that page by id must include `id = 0` on the first page (no lower bound, or cursor `< 0`). Prefer `chunkById()` over a hand-rolled `id > 0` cursor. + ### Frontend - Livewire 3 components with Alpine.js for client-side interactivity - Blade templates in `resources/views/livewire/` diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 6e2fab14a..156f08d01 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -149,8 +149,8 @@ class ScheduledJobManager implements ShouldQueue private function processScheduledBackupsAndTasks(): void { - $lastBackupId = 0; - $lastTaskId = 0; + $lastBackupId = null; + $lastTaskId = null; do { $backups = $this->scheduledBackupQuery($lastBackupId)->get(); @@ -190,16 +190,16 @@ class ScheduledJobManager implements ShouldQueue } } - private function scheduledBackupQuery(int $lastBackupId): Builder + private function scheduledBackupQuery(?int $lastBackupId): Builder { return ScheduledDatabaseBackup::with(['database', 'team.subscription']) ->where('enabled', true) - ->where('id', '>', $lastBackupId) + ->when($lastBackupId !== null, fn (Builder $query) => $query->where('id', '>', $lastBackupId)) ->orderBy('id') ->limit(self::CHUNK_SIZE); } - private function scheduledTaskQuery(int $lastTaskId): Builder + private function scheduledTaskQuery(?int $lastTaskId): Builder { return ScheduledTask::with([ 'service.destination.server.settings', @@ -208,7 +208,7 @@ class ScheduledJobManager implements ShouldQueue 'application.destination.server.team.subscription', ]) ->where('enabled', true) - ->where('id', '>', $lastTaskId) + ->when($lastTaskId !== null, fn (Builder $query) => $query->where('id', '>', $lastTaskId)) ->orderBy('id') ->limit(self::CHUNK_SIZE); } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 92829edd6..de79fcd24 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -101,6 +101,7 @@ class StackForm extends Component $rules = data_get($field, 'rules', 'nullable'); $isPassword = data_get($field, 'isPassword', false); $customHelper = data_get($field, 'customHelper', false); + $sortOrder = data_get($field, 'sortOrder'); $this->fields->put($key, [ 'serviceName' => $serviceName, 'key' => $key, @@ -109,6 +110,7 @@ class StackForm extends Component 'isPassword' => $isPassword, 'rules' => $rules, 'customHelper' => $customHelper, + 'sortOrder' => $sortOrder, ]); $this->validationAttributes["fields.$key.value"] = $fieldKey; @@ -116,7 +118,7 @@ class StackForm extends Component } $this->fields = $this->fields->groupBy('serviceName')->map(function ($group) { return $group->sortBy(function ($field) { - return data_get($field, 'isPassword') ? 1 : 0; + return data_get($field, 'sortOrder') ?? (data_get($field, 'isPassword') ? 1 : 0); })->mapWithKeys(function ($field) { return [$field['key'] => $field]; }); diff --git a/app/Models/Service.php b/app/Models/Service.php index 224507c4e..0da97b301 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1180,6 +1180,27 @@ class Service extends BaseModel } $fields->put('Openclaw', $data->toArray()); break; + case $image->contains('coollabsio/jean-server'): + $data = collect([]); + $settings = [ + 'Token' => ['key' => 'SERVICE_PASSWORD_64_JEAN', 'rules' => 'required', 'isPassword' => true, 'sortOrder' => 1, 'customHelper' => 'Token required to access Jean Server. Variable name: SERVICE_PASSWORD_64_JEAN'], + 'Allowed Origins' => ['key' => 'JEAN_ALLOWED_ORIGINS', 'rules' => 'nullable|string', 'sortOrder' => 2, 'customHelper' => 'Comma-separated additional browser origins. Same-origin access is always allowed. Variable name: JEAN_ALLOWED_ORIGINS'], + ]; + + foreach ($settings as $label => $setting) { + $variable = $this->environment_variables()->where('key', $setting['key'])->first(); + if (! $variable) { + continue; + } + + $data->put($label, [ + ...$setting, + 'value' => data_get($variable, 'value'), + ]); + } + + $fields->put('', $data->toArray()); + break; default: $data = collect([]); $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 20b3752f5..712f09a48 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -15,9 +15,16 @@ trait HasMetrics public function getMemoryMetrics(int $mins = 5): ?array { - $field = $this->isServerMetrics() ? 'usedPercent' : 'used'; + if ($this->isServerMetrics()) { + return $this->getMetrics('memory', $mins, 'usedPercent'); + } - return $this->getMetrics('memory', $mins, $field); + $metrics = $this->getMetrics('memory', $mins, 'used'); + if ($metrics === null) { + return null; + } + + return convertContainerMemoryBytesToMegabytes($metrics); } private function getMetrics(string $type, int $mins, string $valueField): ?array diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index b183f5903..cf63c4776 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4724,6 +4724,23 @@ function downsampleLTTB(array $data, int $threshold): array return $sampled; } +/** + * Convert Sentinel container memory samples from bytes to megabytes. + * + * Sentinel stores container `used` memory in bytes. Application and database + * metric charts label the series as megabytes, so the values must be converted + * before they are sent to the frontend. + * + * @param array $metrics + * @return array + */ +function convertContainerMemoryBytesToMegabytes(array $metrics): array +{ + return array_map(static function (array $point): array { + return [(int) $point[0], round(((float) $point[1]) / 1024 / 1024, 2)]; + }, $metrics); +} + /** * Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}. * diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index 89dfa0e5c..d6c55ecfe 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -66,7 +66,12 @@ @foreach ($fields as $serviceName => $field)
- {{ data_get($field, 'serviceName') }} · {{ data_get($field, 'name') }} + + @if (filled(data_get($field, 'serviceName'))) + {{ data_get($field, 'serviceName') }} · + @endif + {{ data_get($field, 'name') }} + @if (data_get($field, 'customHelper')) @else diff --git a/svgs/jean.png b/svgs/jean.png new file mode 100644 index 000000000..b5f15fdc2 Binary files /dev/null and b/svgs/jean.png differ diff --git a/templates/compose/jean.yaml b/templates/compose/jean.yaml new file mode 100644 index 000000000..4b24ee02d --- /dev/null +++ b/templates/compose/jean.yaml @@ -0,0 +1,31 @@ +# documentation: https://github.com/coollabsio/jean/blob/main/docs/headless-server.md +# slogan: Open-source desktop and server client for orchestrating AI coding agents. +# category: development +# tags: jean,ai,coding,agents,development,git,worktrees +# logo: svgs/jean.png +# port: 3456 + +services: + jean: + image: 'ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}' + environment: + - SERVICE_URL_JEAN_3456 + - JEAN_HEADLESS=${JEAN_HEADLESS:-1} + - JEAN_HOST=${JEAN_HOST:-0.0.0.0} + - JEAN_PORT=${JEAN_PORT:-3456} + - JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN} + - JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0} + - JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0} + - JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0} + - JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-} + - JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop + volumes: + - jean-data:/home/jean/.local/share/com.jean.desktop + healthcheck: + test: + - CMD-SHELL + - 'curl -fsS http://127.0.0.1:3456/readyz >/dev/null || exit 1' + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index cda94584b..f90fa3790 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVBTl8zNDU2CiAgICAgIC0gJ0pFQU5fSEVBRExFU1M9JHtKRUFOX0hFQURMRVNTOi0xfScKICAgICAgLSAnSkVBTl9IT1NUPSR7SkVBTl9IT1NUOi0wLjAuMC4wfScKICAgICAgLSAnSkVBTl9QT1JUPSR7SkVBTl9QT1JUOi0zNDU2fScKICAgICAgLSAnSkVBTl9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSkVBTn0nCiAgICAgIC0gJ0pFQU5fTk9fVE9LRU49JHtKRUFOX05PX1RPS0VOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU49JHtKRUFOX0FMTE9XX1VOU0FGRV9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfTkFUSVZFX09QRU49JHtKRUFOX0FMTE9XX05BVElWRV9PUEVOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV0VEX09SSUdJTlM9JHtKRUFOX0FMTE9XRURfT1JJR0lOUzotfScKICAgICAgLSBKRUFOX0RBVEFfRElSPS9ob21lL2plYW4vLmxvY2FsL3NoYXJlL2NvbS5qZWFuLmRlc2t0b3AKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plYW4tZGF0YTovaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTozNDU2L3JlYWR5eiA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 50f647315..5a4ad35be 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFQU5fMzQ1NgogICAgICAtICdKRUFOX0hFQURMRVNTPSR7SkVBTl9IRUFETEVTUzotMX0nCiAgICAgIC0gJ0pFQU5fSE9TVD0ke0pFQU5fSE9TVDotMC4wLjAuMH0nCiAgICAgIC0gJ0pFQU5fUE9SVD0ke0pFQU5fUE9SVDotMzQ1Nn0nCiAgICAgIC0gJ0pFQU5fVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0pFQU59JwogICAgICAtICdKRUFOX05PX1RPS0VOPSR7SkVBTl9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfVU5TQUZFX05PX1RPS0VOPSR7SkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU46LTB9JwogICAgICAtICdKRUFOX0FMTE9XX05BVElWRV9PUEVOPSR7SkVBTl9BTExPV19OQVRJVkVfT1BFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dFRF9PUklHSU5TPSR7SkVBTl9BTExPV0VEX09SSUdJTlM6LX0nCiAgICAgIC0gSkVBTl9EQVRBX0RJUj0vaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wCiAgICB2b2x1bWVzOgogICAgICAtICdqZWFuLWRhdGE6L2hvbWUvamVhbi8ubG9jYWwvc2hhcmUvY29tLmplYW4uZGVza3RvcCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIGh0dHA6Ly8xMjcuMC4wLjE6MzQ1Ni9yZWFkeXogPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMK", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/tests/Feature/ScheduledJobManagerDispatchTest.php b/tests/Feature/ScheduledJobManagerDispatchTest.php index a9cda44d2..b18cc98e4 100644 --- a/tests/Feature/ScheduledJobManagerDispatchTest.php +++ b/tests/Feature/ScheduledJobManagerDispatchTest.php @@ -1,14 +1,17 @@ id}"))->not->toBeNull(); }); +it('dispatches the instance coolify-db backup even when its id is zero', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC')); + Queue::fake(); + + $database = createScheduledBackupDatabase(); + $backup = createScheduledDatabaseBackup($database, [ + 'id' => 0, + 'frequency' => '* * * * *', + ]); + + expect($backup->id)->toBe(0); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed(DatabaseBackupJob::class, 1); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0); +}); + +it('dispatches zero-id schedules and continues with positive ids', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC')); + Queue::fake(); + + $application = createScheduledTaskApplication(); + $database = StandalonePostgresql::create([ + 'name' => 'coolify-db', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'running', + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + + $zeroIdBackup = createScheduledDatabaseBackup($database, ['id' => 0]); + $positiveIdBackup = createScheduledDatabaseBackup($database); + $zeroIdTask = createScheduledApplicationTask($application, ['id' => 0]); + $positiveIdTask = createScheduledApplicationTask($application); + + expect($zeroIdBackup->id)->toBe(0) + ->and($positiveIdBackup->id)->toBeGreaterThan(0) + ->and($zeroIdTask->id)->toBe(0) + ->and($positiveIdTask->id)->toBeGreaterThan(0); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed(DatabaseBackupJob::class, 2); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0); + Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === $positiveIdBackup->id); + Queue::assertPushed(ScheduledTaskJob::class, 2); + Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === 0); + Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === $positiveIdTask->id); +}); + it('does not query relationships when constructing scheduled task jobs', function () { $application = createScheduledTaskApplication(); @@ -148,3 +208,53 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== 'status' => 'running', ]); } + +function createScheduledBackupDatabase(): StandalonePostgresql +{ + $application = createScheduledTaskApplication(); + + return StandalonePostgresql::create([ + 'name' => 'coolify-db', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'running', + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); +} + +function createScheduledDatabaseBackup(StandalonePostgresql $database, array $overrides = []): ScheduledDatabaseBackup +{ + $backup = new ScheduledDatabaseBackup; + $backup->forceFill(array_merge([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '* * * * *', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $database->environment->project->team_id, + ], $overrides)); + $backup->save(); + + return $backup->fresh(); +} + +function createScheduledApplicationTask(Application $application, array $overrides = []): ScheduledTask +{ + $task = new ScheduledTask; + $task->forceFill(array_merge([ + 'name' => 'scheduled-task', + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => $application->environment->project->team_id, + 'application_id' => $application->id, + ], $overrides)); + $task->save(); + + return $task->fresh(); +} diff --git a/tests/Feature/ServiceExtraFieldsTest.php b/tests/Feature/ServiceExtraFieldsTest.php index 2b6ed0c0b..2a521f97e 100644 --- a/tests/Feature/ServiceExtraFieldsTest.php +++ b/tests/Feature/ServiceExtraFieldsTest.php @@ -45,3 +45,30 @@ it('only adds Grafana extra fields for Grafana server images', function (string 'promtail' => ['grafana/promtail:latest', false], 'tempo' => ['grafana/tempo:latest', false], ]); + +it('exposes Jean Server authentication and access settings', function () { + $service = serviceExtraFieldsTestServiceWithApplicationImage('ghcr.io/coollabsio/jean-server:latest'); + + $service->environment_variables()->createMany([ + ['key' => 'SERVICE_PASSWORD_64_JEAN', 'value' => 'secret-token', 'is_preview' => false], + ['key' => 'JEAN_ALLOWED_ORIGINS', 'value' => 'https://jean.example.com', 'is_preview' => false], + ]); + + $fields = $service->extraFields(); + + expect($fields)->toHaveKey('') + ->and($fields[''])->toMatchArray([ + 'Token' => [ + 'key' => 'SERVICE_PASSWORD_64_JEAN', + 'value' => 'secret-token', + 'rules' => 'required', + 'isPassword' => true, + 'sortOrder' => 1, + ], + 'Allowed Origins' => [ + 'key' => 'JEAN_ALLOWED_ORIGINS', + 'value' => 'https://jean.example.com', + 'sortOrder' => 2, + ], + ]); +}); diff --git a/tests/Unit/ContainerMemoryMetricsConversionTest.php b/tests/Unit/ContainerMemoryMetricsConversionTest.php new file mode 100644 index 000000000..8ad4ca79e --- /dev/null +++ b/tests/Unit/ContainerMemoryMetricsConversionTest.php @@ -0,0 +1,33 @@ +toBe([ + [1_700_000_000_000, 81.06], + [1_700_000_005_000, 100.0], + ]); +}); + +it('preserves timestamps and converts a zero byte sample to zero megabytes', function () { + expect(convertContainerMemoryBytesToMegabytes([ + [1_700_000_000_000, 0.0], + ]))->toBe([ + [1_700_000_000_000, 0.0], + ]); +}); + +it('leaves an empty series unchanged', function () { + expect(convertContainerMemoryBytesToMegabytes([]))->toBe([]); +}); diff --git a/tests/Unit/JeanServiceTemplateTest.php b/tests/Unit/JeanServiceTemplateTest.php new file mode 100644 index 000000000..32de62ed3 --- /dev/null +++ b/tests/Unit/JeanServiceTemplateTest.php @@ -0,0 +1,39 @@ +toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('SERVICE_URL_JEAN_3456') + ->toContain('JEAN_HEADLESS=${JEAN_HEADLESS:-1}') + ->toContain('JEAN_HOST=${JEAN_HOST:-0.0.0.0}') + ->toContain('JEAN_PORT=${JEAN_PORT:-3456}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}') + ->toContain('JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0}') + ->toContain('JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-}') + ->toContain('JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop') + ->toContain('jean-data:/home/jean/.local/share/com.jean.desktop') + ->toContain('http://127.0.0.1:3456/readyz'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templates = json_decode( + file_get_contents(__DIR__."/../../templates/{$templateFile}"), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + expect($templates)->toHaveKey('jean'); + expect($templates['jean']['port'] ?? null)->toBe('3456'); + expect($templates['jean']['logo'] ?? null)->toBe('svgs/jean.png'); + expect($templates['jean']['category'] ?? null)->toBe('development'); + + $generatedCompose = base64_decode($templates['jean']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}'); + } +});