@endif
- @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
-
-
- Hardcoded variables are not shown here.
-
- {{-- If you would like to add a variable, you must add it to
- your compose file.
--}}
- @endif
@if ($view === 'normal')
@@ -61,31 +48,48 @@
Environment (secrets) variables for Production.
@forelse ($this->environmentVariables as $env)
-
+
@empty
No environment variables found.
@endforelse
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariables as $index => $env)
+
+ @endforeach
+ @endif
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
Preview Deployments Environment Variables
Environment (secrets) variables for Preview Deployments.
@foreach ($this->environmentVariablesPreview as $env)
-
+
@endforeach
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
+
+ @endforeach
+ @endif
@endif
@else
@endif
-
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
new file mode 100644
index 000000000..9158d127e
--- /dev/null
+++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
@@ -0,0 +1,31 @@
+
+
+
+
+ Hardcoded env
+
+ @if($serviceName)
+
+ Service: {{ $serviceName }}
+
+ @endif
+
+
+
+
+ @if($value !== null && $value !== '')
+
+ @else
+
+ @endif
+
+ @if($comment)
+
+ @endif
+
+
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index 68e1d7e7d..86faeeeb4 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -15,13 +15,21 @@
@can('delete', $this->env)
@endcan
@can('update', $this->env)
+
@if (!$is_redis_credential)
@@ -32,28 +40,40 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
-
- @endif
-
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -72,82 +92,95 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
- @endif
-
-
- @if ($is_multiline === false)
-
+
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@endif
+
+
+
@endcan
@else
@can('update', $this->env)
@if ($isDisabled)
+
+
+
+
+ @if ($is_shared)
+
+ @endif
+
+ @if (!$isMagicVariable)
+
+ @endif
+
+ @else
+
+
+ @if ($is_multiline)
+
+
+ @else
+
+
+ @endif
+ @if ($is_shared)
+
+ @endif
+
+
+
+ @endif
+ @else
+
-
+
@if ($is_shared)
@endif
- @else
-
- @if ($is_multiline)
-
-
- @else
-
-
- @endif
- @if ($is_shared)
-
- @endif
-
- @endif
- @else
-
-
-
- @if ($is_shared)
-
+ @if (!$isMagicVariable)
+
@endif
@endcan
@@ -162,28 +195,40 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
-
- @endif
-
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -191,12 +236,13 @@
@endif
-
- @if ($isDisabled)
+ @if (!$isMagicVariable)
+
+ @if ($isDisabled)
Update
Lock
- Update
Lock
-
- @endif
-
+ @endif
+
+ @endif
@else
@@ -224,27 +271,37 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
- @endif
-
-
- @if ($is_multiline === false)
-
+
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -255,4 +312,4 @@
@endif
-
+
\ No newline at end of file
diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php
new file mode 100644
index 000000000..e7f9a07fb
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableCommentTest.php
@@ -0,0 +1,283 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+test('environment variable can be created with comment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'This is a test environment variable',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('This is a test environment variable');
+ expect($env->key)->toBe('TEST_VAR');
+ expect($env->value)->toBe('test_value');
+});
+
+test('environment variable comment is optional', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+ expect($env->key)->toBe('TEST_VAR');
+});
+
+test('environment variable comment can be updated', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->comment = 'Updated comment';
+ $env->save();
+
+ $env->refresh();
+ expect($env->comment)->toBe('Updated comment');
+});
+
+test('environment variable comment is preserved when updating value', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Important variable for testing',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->value = 'new_value';
+ $env->save();
+
+ $env->refresh();
+ expect($env->value)->toBe('new_value');
+ expect($env->comment)->toBe('Important variable for testing');
+});
+
+test('environment variable comment is copied to preview environment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The model's created() event listener automatically creates a preview version
+ $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
+ ->where('resourceable_id', $this->application->id)
+ ->where('is_preview', true)
+ ->first();
+
+ expect($previewEnv)->not->toBeNull();
+ expect($previewEnv->comment)->toBe('Test comment');
+});
+
+test('parseEnvFormatToArray preserves values without inline comments', function () {
+ $input = "KEY1=value1\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('developer view format does not break with comment-like values', function () {
+ // Values that contain # but shouldn't be treated as comments when quoted
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'HASH_VAR',
+ 'value' => 'value_with_#_in_it',
+ 'comment' => 'Contains hash symbol',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env1->value)->toBe('value_with_#_in_it');
+ expect($env1->comment)->toBe('Contains hash symbol');
+});
+
+test('environment variable comment can store up to 256 characters', function () {
+ $comment = str_repeat('a', 256);
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(256);
+});
+
+test('environment variable comment cannot exceed 256 characters via Livewire', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $longComment = str_repeat('a', 257);
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
+ ->set('comment', $longComment)
+ ->call('submit')
+ ->assertHasErrors(['comment' => 'max']);
+});
+
+test('bulk update preserves existing comments when no inline comment provided', function () {
+ // Create existing variable with a manually-entered comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://old-host',
+ 'comment' => 'Production database',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User switches to Developer view and pastes new value without inline comment
+ $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('postgres://new-host');
+
+ // The manually-entered comment should be PRESERVED
+ expect($env->comment)->toBe('Production database');
+});
+
+test('bulk update overwrites existing comments when inline comment provided', function () {
+ // Create existing variable with a comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'old-key',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User pastes new value WITH inline comment
+ $bulkContent = 'API_KEY=new-key #Updated production key';
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('new-key');
+
+ // The comment should be OVERWRITTEN with the inline comment
+ expect($env->comment)->toBe('Updated production key');
+});
+
+test('bulk update handles mixed inline and stored comments correctly', function () {
+ // Create two variables with comments
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITH_COMMENT',
+ 'value' => 'value1',
+ 'comment' => 'Existing comment 1',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env2 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITHOUT_COMMENT',
+ 'value' => 'value2',
+ 'comment' => 'Existing comment 2',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Bulk paste: one with inline comment, one without
+ $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh both variables
+ $env1->refresh();
+ $env2->refresh();
+
+ // First variable: comment should be overwritten with inline comment
+ expect($env1->value)->toBe('new_value1');
+ expect($env1->comment)->toBe('New inline comment');
+
+ // Second variable: comment should be preserved
+ expect($env2->value)->toBe('new_value2');
+ expect($env2->comment)->toBe('Existing comment 2');
+});
+
+test('bulk update creates new variables with inline comments', function () {
+ // Bulk paste creates new variables, some with inline comments
+ $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Check that variables were created with correct comments
+ $var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($var1->value)->toBe('value1');
+ expect($var1->comment)->toBe('Comment for var1');
+
+ expect($var2->value)->toBe('value2');
+ expect($var2->comment)->toBeNull();
+
+ expect($var3->value)->toBe('value3');
+ expect($var3->comment)->toBe('Comment for var3');
+});
diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
new file mode 100644
index 000000000..f2650fdc7
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
@@ -0,0 +1,217 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create();
+
+ $this->actingAs($this->user);
+});
+
+test('all fillable fields can be mass assigned', function () {
+ $data = [
+ 'key' => 'TEST_KEY',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_literal' => true,
+ 'is_multiline' => true,
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'is_shown_once' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ];
+
+ $env = EnvironmentVariable::create($data);
+
+ expect($env->key)->toBe('TEST_KEY');
+ expect($env->value)->toBe('test_value');
+ expect($env->comment)->toBe('Test comment');
+ expect($env->is_literal)->toBeTrue();
+ expect($env->is_multiline)->toBeTrue();
+ expect($env->is_preview)->toBeFalse();
+ expect($env->is_runtime)->toBeTrue();
+ expect($env->is_buildtime)->toBeFalse();
+ expect($env->is_shown_once)->toBeFalse();
+ expect($env->resourceable_type)->toBe(Application::class);
+ expect($env->resourceable_id)->toBe($this->application->id);
+});
+
+test('comment field can be mass assigned with null', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => null,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+});
+
+test('comment field can be mass assigned with empty string', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => '',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('');
+});
+
+test('comment field can be mass assigned with long text', function () {
+ $comment = str_repeat('This is a long comment. ', 10);
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(strlen($comment));
+});
+
+test('all boolean fields default correctly when not provided', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Boolean fields can be null or false depending on database defaults
+ expect($env->is_multiline)->toBeIn([false, null]);
+ expect($env->is_preview)->toBeIn([false, null]);
+ expect($env->is_runtime)->toBeIn([false, null]);
+ expect($env->is_buildtime)->toBeIn([false, null]);
+ expect($env->is_shown_once)->toBeIn([false, null]);
+});
+
+test('value field is properly encrypted when mass assigned', function () {
+ $plainValue = 'secret_value_123';
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'SECRET_KEY',
+ 'value' => $plainValue,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Value should be decrypted when accessed via model
+ expect($env->value)->toBe($plainValue);
+
+ // Verify it's actually encrypted in the database
+ $rawValue = \DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->not->toBe($plainValue);
+ expect($rawValue)->not->toBeNull();
+});
+
+test('key field is trimmed and spaces replaced with underscores', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => ' TEST KEY WITH SPACES ',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
+});
+
+test('version field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'version' => '1.2.3',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The booted() method sets version automatically, so it will be the current version
+ expect($env->version)->not->toBeNull();
+});
+
+test('mass assignment works with update method', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->update([
+ 'value' => 'updated_value',
+ 'comment' => 'Updated comment',
+ 'is_literal' => true,
+ ]);
+
+ $env->refresh();
+
+ expect($env->value)->toBe('updated_value');
+ expect($env->comment)->toBe('Updated comment');
+ expect($env->is_literal)->toBeTrue();
+});
+
+test('protected attributes cannot be mass assigned', function () {
+ $customDate = '2020-01-01 00:00:00';
+
+ $env = EnvironmentVariable::create([
+ 'id' => 999999,
+ 'uuid' => 'custom-uuid',
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'created_at' => $customDate,
+ 'updated_at' => $customDate,
+ ]);
+
+ // id should be auto-generated, not 999999
+ expect($env->id)->not->toBe(999999);
+
+ // uuid should be auto-generated, not 'custom-uuid'
+ expect($env->uuid)->not->toBe('custom-uuid');
+
+ // Timestamps should be current, not 2020
+ expect($env->created_at->year)->toBe(now()->year);
+});
+
+test('order field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'order' => 5,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->order)->toBe(5);
+});
+
+test('is_shared field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'is_shared' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Note: is_shared is also computed via accessor, but can be mass assigned
+ expect($env->is_shared)->not->toBeNull();
+});
diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php
new file mode 100644
index 000000000..8c5f68b21
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableFillableTest.php
@@ -0,0 +1,72 @@
+getFillable();
+
+ // Core identification
+ expect($fillable)->toContain('key')
+ ->toContain('value')
+ ->toContain('comment');
+
+ // Polymorphic relationship
+ expect($fillable)->toContain('resourceable_type')
+ ->toContain('resourceable_id');
+
+ // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
+ expect($fillable)->toContain('is_preview')
+ ->toContain('is_multiline')
+ ->toContain('is_literal')
+ ->toContain('is_runtime')
+ ->toContain('is_buildtime')
+ ->toContain('is_shown_once')
+ ->toContain('is_shared')
+ ->toContain('is_required');
+
+ // Metadata
+ expect($fillable)->toContain('version')
+ ->toContain('order');
+});
+
+test('is_required can be mass assigned', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['is_required' => true]);
+
+ expect($model->is_required)->toBeTrue();
+});
+
+test('all boolean flags can be mass assigned', function () {
+ $booleanFlags = [
+ 'is_preview',
+ 'is_multiline',
+ 'is_literal',
+ 'is_runtime',
+ 'is_buildtime',
+ 'is_shown_once',
+ 'is_required',
+ ];
+
+ $model = new EnvironmentVariable;
+ $model->fill(array_fill_keys($booleanFlags, true));
+
+ foreach ($booleanFlags as $flag) {
+ expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
+ }
+
+ // is_shared has a computed getter derived from the value field,
+ // so verify it's fillable via the underlying attributes instead
+ $model2 = new EnvironmentVariable;
+ $model2->fill(['is_shared' => true]);
+ expect($model2->getAttributes())->toHaveKey('is_shared');
+});
+
+test('non-fillable fields are rejected by mass assignment', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
+
+ expect($model->id)->toBeNull()
+ ->and($model->uuid)->toBeNull()
+ ->and($model->created_at)->toBeNull();
+});
diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php
new file mode 100644
index 000000000..ae85ba45f
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php
@@ -0,0 +1,141 @@
+shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_NAME variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_NAME');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('regular variables are not magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('DATABASE_URL');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isDisabled)->toBeFalse();
+});
+
+test('locked variables are not magic variables unless they start with SERVICE_', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SECRET_KEY');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(true);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isLocked)->toBeTrue();
+});
+
+test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB_5432');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API_8080');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
new file mode 100644
index 000000000..a52d7dba5
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
@@ -0,0 +1,351 @@
+toBe('');
+});
+
+test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
+ $split = splitOnOperatorOutsideNested(':-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent handles double opening brace', function () {
+ $result = extractBalancedBraceContent('${{VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('{VAR}');
+});
+
+test('extractBalancedBraceContent returns null for empty string', function () {
+ $result = extractBalancedBraceContent('', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just dollar sign', function () {
+ $result = extractBalancedBraceContent('$', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just opening brace', function () {
+ $result = extractBalancedBraceContent('{', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just closing brace', function () {
+ $result = extractBalancedBraceContent('}', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles extra closing brace', function () {
+ $result = extractBalancedBraceContent('${VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+test('extractBalancedBraceContent returns null for unclosed with no content', function () {
+ $result = extractBalancedBraceContent('${', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
+
+ assertNull($result);
+});
+
+test('replaceVariables handles empty braces gracefully', function () {
+ $result = replaceVariables('${}');
+
+ expect($result->value())->toBe('');
+});
+
+test('replaceVariables handles double braces gracefully', function () {
+ $result = replaceVariables('${{VAR}}');
+
+ expect($result->value())->toBe('{VAR}');
+});
+
+// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
+
+test('extractBalancedBraceContent finds consecutive variables', function () {
+ $str = '${A}${B}';
+
+ $first = extractBalancedBraceContent($str, 0);
+ assertNotNull($first);
+ expect($first['content'])->toBe('A');
+
+ $second = extractBalancedBraceContent($str, $first['end'] + 1);
+ assertNotNull($second);
+ expect($second['content'])->toBe('B');
+});
+
+test('splitOnOperatorOutsideNested handles URL with port in default', function () {
+ $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('http://host:8080/path');
+});
+
+test('splitOnOperatorOutsideNested handles equals sign in default', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('key=value&foo=bar');
+});
+
+test('splitOnOperatorOutsideNested handles dashes in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-value-with-dashes');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('value-with-dashes');
+});
+
+test('splitOnOperatorOutsideNested handles question mark in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-what?');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('what?');
+});
+
+test('extractBalancedBraceContent handles variable with digits', function () {
+ $result = extractBalancedBraceContent('${VAR123}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR123');
+});
+
+test('extractBalancedBraceContent handles long variable name', function () {
+ $longName = str_repeat('A', 200);
+ $result = extractBalancedBraceContent('${'.$longName.'}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe($longName);
+});
+
+test('splitOnOperatorOutsideNested returns null for empty string', function () {
+ $split = splitOnOperatorOutsideNested('');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
+ $split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('_MY_VAR_')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent with startPos beyond string length', function () {
+ $result = extractBalancedBraceContent('${VAR}', 100);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles brace in middle of text', function () {
+ $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
+
+test('extractBalancedBraceContent handles four levels of nesting', function () {
+ $input = '${A:-${B:-${C:-${D}}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
+});
+
+test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
+ $content = 'A:-${B:-${C:-${D}}}';
+ $split = splitOnOperatorOutsideNested($content);
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-${C:-${D}}}');
+
+ // Verify second level
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ assertNotNull($nested);
+ $split2 = splitOnOperatorOutsideNested($nested['content']);
+ assertNotNull($split2);
+ expect($split2['variable'])->toBe('B')
+ ->and($split2['default'])->toBe('${C:-${D}}');
+});
+
+test('multiple variables at same depth in default', function () {
+ $input = '${A:-${B}/${C}/${D}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['default'])->toBe('${B}/${C}/${D}');
+
+ // Verify all three nested variables can be found
+ $default = $split['default'];
+ $vars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $vars[] = $nested['content'];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($vars)->toBe(['B', 'C', 'D']);
+});
+
+test('nested with mixed operators', function () {
+ $input = '${A:-${B:?required}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:?required}');
+
+ // Inner variable uses :? operator
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ expect($innerSplit['variable'])->toBe('B')
+ ->and($innerSplit['operator'])->toBe(':?')
+ ->and($innerSplit['default'])->toBe('required');
+});
+
+test('nested variable without default as default', function () {
+ $input = '${A:-${B}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${B}');
+
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ assertNull($innerSplit);
+ expect($nested['content'])->toBe('B');
+});
+
+// ─── Backwards Compatibility ───────────────────────────────────────────────────
+
+test('replaceVariables with brace format without dollar sign', function () {
+ $result = replaceVariables('{MY_VAR}');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with truncated brace format', function () {
+ $result = replaceVariables('{MY_VAR');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with plain string returns unchanged', function () {
+ $result = replaceVariables('plain_value');
+
+ expect($result->value())->toBe('plain_value');
+});
+
+test('replaceVariables preserves full content for variable with default', function () {
+ $result = replaceVariables('${DB_HOST:-localhost}');
+
+ expect($result->value())->toBe('DB_HOST:-localhost');
+});
+
+test('replaceVariables preserves nested content for variable with nested default', function () {
+ $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
+
+ expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
+});
+
+test('replaceVariables with brace format containing default falls back gracefully', function () {
+ $result = replaceVariables('{VAR:-default}');
+
+ expect($result->value())->toBe('VAR:-default');
+});
+
+test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-val-ue');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':-')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('val-ue');
+});
+
+test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error?');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':?')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('error?');
+});
+
+test('full round trip: extract, split, and resolve nested variables', function () {
+ $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
+
+ // Step 1: Extract outer content
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+ expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 2: Split on outer operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['variable'])->toBe('APP_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 3: Find all nested variables in default
+ $default = $split['default'];
+ $nestedVars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+ $nestedVars[] = [
+ 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
+ 'default' => $innerSplit !== null ? $innerSplit['default'] : null,
+ ];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($nestedVars)->toHaveCount(2)
+ ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
+ ->and($nestedVars[0]['default'])->toBeNull()
+ ->and($nestedVars[1]['name'])->toBe('API_VERSION')
+ ->and($nestedVars[1]['default'])->toBe('2');
+});
diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
new file mode 100644
index 000000000..8d8caacaf
--- /dev/null
+++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
@@ -0,0 +1,147 @@
+toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe('3000')
+ ->and($result[1]['service_name'])->toBe('app');
+});
+
+test('extracts environment variables with inline comments', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production # Production environment
+ - DEBUG=false # Disable debug mode
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['comment'])->toBe('Production environment')
+ ->and($result[1]['comment'])->toBe('Disable debug mode');
+});
+
+test('handles multiple services', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - APP_ENV=prod
+ db:
+ environment:
+ - POSTGRES_DB=mydb
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('APP_ENV')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('POSTGRES_DB')
+ ->and($result[1]['service_name'])->toBe('db');
+});
+
+test('handles associative array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
+});
+
+test('handles environment variables without values', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - API_KEY
+ - DEBUG=false
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('API_KEY')
+ ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
+ ->and($result[1]['key'])->toBe('DEBUG')
+ ->and($result[1]['value'])->toBe('false');
+});
+
+test('returns empty collection for malformed YAML', function () {
+ $yaml = 'invalid: yaml: content::: [[[';
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection for empty compose file', function () {
+ $result = extractHardcodedEnvironmentVariables('');
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when no services defined', function () {
+ $yaml = <<<'YAML'
+version: '3.8'
+networks:
+ default:
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when service has no environment section', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ image: nginx
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('handles mixed associative and array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ // Mixed format is invalid YAML and returns empty collection
+ expect($result)->toBeEmpty();
+});
diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
new file mode 100644
index 000000000..4300b3abf
--- /dev/null
+++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
@@ -0,0 +1,334 @@
+toBe([]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # This is a comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - FOO=bar # This is a comment
+ - BAZ=qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ COLOR: "#FF0000" # hex color code
+ DB_URL: "postgres://user:pass#123@localhost" # database URL
+ PLAIN: value # no quotes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'COLOR' => 'hex color code',
+ 'DB_URL' => 'database URL',
+ 'PLAIN' => 'no quotes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ PASSWORD: 'secret#123' # my password
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'PASSWORD' => 'my password',
+ ]);
+});
+
+test('extractYamlEnvironmentComments skips full-line comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ # This is a full line comment
+ FOO: bar # This is an inline comment
+ # Another full line comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is an inline comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles multiple services', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ WEB_PORT: 8080 # web server port
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: admin # database admin user
+ POSTGRES_PASSWORD: secret
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'WEB_PORT' => 'web server port',
+ 'POSTGRES_USER' => 'database admin user',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables without values', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DEBUG # enable debug mode
+ - VERBOSE
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DEBUG' => 'enable debug mode',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles array format with colons', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DATABASE_URL: postgres://localhost # connection string
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => 'connection string',
+ ]);
+});
+
+test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ API_KEY: abc#def
+ OTHER: xyz # this is a comment
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // abc#def has no space before #, so it's not treated as a comment
+ expect($result)->toBe([
+ 'OTHER' => 'this is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles empty environment section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ ports:
+ - "80:80"
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
+ // Inline format like environment: { FOO: bar } is not supported for comment extraction
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment: { FOO: bar }
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // No comments extracted from inline format
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+
+services:
+ app:
+ image: myapp:latest
+ environment:
+ NODE_ENV: production # Set to development for local
+ DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
+ REDIS_URL: "redis://cache:6379"
+ API_SECRET: "${API_SECRET}" # From .env file
+ LOG_LEVEL: debug # Options: debug, info, warn, error
+ ports:
+ - "3000:3000"
+
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: user # Database admin username
+ POSTGRES_PASSWORD: "${DB_PASSWORD}"
+ POSTGRES_DB: mydb
+
+ cache:
+ image: redis:7
+ environment:
+ - REDIS_MAXMEMORY=256mb # Memory limit for cache
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'NODE_ENV' => 'Set to development for local',
+ 'DATABASE_URL' => 'Main database',
+ 'API_SECRET' => 'From .env file',
+ 'LOG_LEVEL' => 'Options: debug, info, warn, error',
+ 'POSTGRES_USER' => 'Database admin username',
+ 'REDIS_MAXMEMORY' => 'Memory limit for cache',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar # comment # with # hashes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'comment # with # hashes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables with empty comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar #
+ BAZ: qux #
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Empty comments should not be included
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # env comment
+ ports:
+ - "80:80" # port comment should not be captured
+ volumes:
+ - ./data:/data # volume comment should not be captured
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Only environment variables should have comments extracted
+ expect($result)->toBe([
+ 'FOO' => 'env comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ SERVICE_FQDN_WEB: /api # Path for the web service
+ SERVICE_URL_WEB: # URL will be generated
+ NORMAL_VAR: value # Regular variable
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'SERVICE_FQDN_WEB' => 'Path for the web service',
+ 'SERVICE_URL_WEB' => 'URL will be generated',
+ 'NORMAL_VAR' => 'Regular variable',
+ ]);
+});
diff --git a/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php
new file mode 100644
index 000000000..546e24a97
--- /dev/null
+++ b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php
@@ -0,0 +1,121 @@
+ ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBe('This is a comment');
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles plain string format gracefully', function () {
+ // Simulate a scenario where parseEnvFormatToArray might return plain strings
+ // (for backward compatibility or edge cases)
+ $variables = [
+ 'KEY1' => 'value1',
+ 'KEY2' => 'value2',
+ 'KEY3' => 'value3',
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($comment)->toBeNull();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+ }
+});
+
+test('DockerCompose handles mixed array and string formats', function () {
+ // Simulate a mixed scenario (unlikely but possible)
+ $variables = [
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'KEY2' => 'value2', // Plain string
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ 'KEY4' => 'value4', // Plain string
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBe('comment1');
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY4') {
+ expect($value)->toBe('value4');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles empty array values gracefully', function () {
+ // Simulate edge case with incomplete array structure
+ $variables = [
+ 'KEY1' => ['value' => 'value1'], // Missing 'comment' key
+ 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
+ 'KEY3' => [], // Empty array (edge case)
+ ];
+
+ // Test the extraction logic with improved fallback
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction doesn't crash
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ // If 'value' is missing, fallback to empty string (not the whole array)
+ expect($value)->toBe('');
+ expect($comment)->toBe('comment2');
+ } elseif ($key === 'KEY3') {
+ // If both are missing, fallback to empty string (not empty array)
+ expect($value)->toBe('');
+ expect($comment)->toBeNull();
+ }
+ }
+});
diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php
new file mode 100644
index 000000000..65e8738cc
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php
@@ -0,0 +1,220 @@
+not->toBeNull()
+ ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split)->not->toBeNull()
+ ->and($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables correctly extracts nested variable content', function () {
+ // Before the fix, this would incorrectly extract only up to the first closing brace
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
+});
+
+test('nested defaults with path concatenation work', function () {
+ $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('REDIS_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
+});
+
+test('deeply nested variables are handled', function () {
+ // Three levels of nesting
+ $input = '${A:-${B:-${C}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['default'])->toBe('${B:-${C}}');
+});
+
+test('multiple nested variables in default value', function () {
+ // Default value contains multiple variable references
+ $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API')
+ ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
+});
+
+test('nested variables with different operators', function () {
+ // Nested variable uses different operator
+ $input = '${API_URL:-${SERVICE_URL?error message}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
+});
+
+test('backward compatibility with simple variables', function () {
+ // Simple variable without nesting should still work
+ $input = '${VAR}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('backward compatibility with single-level defaults', function () {
+ // Single-level default without nesting
+ $input = '${VAR:-default_value}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR:-default_value');
+
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('backward compatibility with dash operator', function () {
+ $input = '${VAR-default}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('-');
+});
+
+test('backward compatibility with colon question operator', function () {
+ $input = '${VAR:?error message}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('backward compatibility with question operator', function () {
+ $input = '${VAR?error}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('SERVICE_URL magic variables in nested defaults', function () {
+ // Real-world scenario: SERVICE_URL_* magic variable used in nested default
+ $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('DATABASE_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
+
+ // Extract the nested SERVICE_URL variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
+});
+
+test('SERVICE_FQDN magic variables in nested defaults', function () {
+ $input = '${API_HOST:-${SERVICE_FQDN_API}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_FQDN_API}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
+});
+
+test('complex real-world example', function () {
+ // Complex real-world scenario from the bug report
+ $input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
+
+ // Step 1: Extract outer variable content
+ $result = extractBalancedBraceContent($input, 0);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ // Step 2: Split on operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ expect($split['variable'])->toBe('API_URL');
+ expect($split['operator'])->toBe(':-');
+ expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+
+ // Step 3: Extract nested variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
+
+ // This verifies that:
+ // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
+ // 2. SERVICE_URL_YOLO should be recognized and created as magic variable
+});
+
+test('empty nested default values', function () {
+ $input = '${VAR:-${NESTED:-}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${NESTED:-}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
+
+ expect($nestedSplit['default'])->toBe('');
+});
+
+test('nested variables with complex paths', function () {
+ $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
+});
+
+test('operator precedence with nesting', function () {
+ // The first :- at depth 0 should be used, not the one inside nested braces
+ $input = '${A:-${B:-default}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ // Should split on first :- (at depth 0)
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}'); // Not split here
+});
diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php
new file mode 100644
index 000000000..81b440927
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableTest.php
@@ -0,0 +1,207 @@
+toBe('VAR')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(5);
+});
+
+test('extractBalancedBraceContent handles nested braces', function () {
+ $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(34); // Position of closing }
+});
+
+test('extractBalancedBraceContent handles triple nesting', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+});
+
+test('extractBalancedBraceContent returns null for unbalanced braces', function () {
+ $result = extractBalancedBraceContent('${VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null when no braces', function () {
+ $result = extractBalancedBraceContent('VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles startPos parameter', function () {
+ $result = extractBalancedBraceContent('foo ${VAR} bar', 4);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR')
+ ->and($result['start'])->toBe(5)
+ ->and($result['end'])->toBe(9);
+});
+
+test('splitOnOperatorOutsideNested splits on :- operator', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-default_value');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('splitOnOperatorOutsideNested handles nested defaults', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('splitOnOperatorOutsideNested handles dash operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('splitOnOperatorOutsideNested handles colon question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error message');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('splitOnOperatorOutsideNested handles question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR?error');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('splitOnOperatorOutsideNested returns null for simple variable', function () {
+ $split = splitOnOperatorOutsideNested('SIMPLE_VAR');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
+ $split = splitOnOperatorOutsideNested('A:-${B:-default}');
+
+ assertNotNull($split);
+ // Should split on first :- (outside nested braces), not the one inside ${B:-default}
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}');
+});
+
+test('replaceVariables handles simple variable', function () {
+ $result = replaceVariables('${VAR}');
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('replaceVariables handles nested expressions', function () {
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables handles variable with default', function () {
+ $result = replaceVariables('${API_URL:-http://localhost}');
+
+ expect($result->value())->toBe('API_URL:-http://localhost');
+});
+
+test('replaceVariables returns unchanged for non-variable string', function () {
+ $result = replaceVariables('not_a_variable');
+
+ expect($result->value())->toBe('not_a_variable');
+});
+
+test('replaceVariables handles triple nesting', function () {
+ $result = replaceVariables('${A:-${B:-${C}}}');
+
+ expect($result->value())->toBe('A:-${B:-${C}}');
+});
+
+test('replaceVariables fallback works for malformed input', function () {
+ // When braces are unbalanced, it falls back to old behavior
+ $result = replaceVariables('${VAR');
+
+ // Old behavior would extract everything before first }
+ // But since there's no }, it will extract 'VAR' (removing ${)
+ expect($result->value())->toContain('VAR');
+});
+
+test('extractBalancedBraceContent handles complex nested expression', function () {
+ $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('splitOnOperatorOutsideNested handles complex nested expression', function () {
+ $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('extractBalancedBraceContent finds second variable in string', function () {
+ $str = '${VAR1} and ${VAR2}';
+
+ // First variable
+ $result1 = extractBalancedBraceContent($str, 0);
+ assertNotNull($result1);
+ expect($result1['content'])->toBe('VAR1');
+
+ // Second variable
+ $result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
+ assertNotNull($result2);
+ expect($result2['content'])->toBe('VAR2');
+});
+
+test('replaceVariables handles empty default value', function () {
+ $result = replaceVariables('${VAR:-}');
+
+ expect($result->value())->toBe('VAR:-');
+});
+
+test('splitOnOperatorOutsideNested handles empty default value', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('');
+});
+
+test('replaceVariables handles brace format without dollar sign', function () {
+ // This format is used by the regex capture group in magic variable detection
+ $result = replaceVariables('{SERVICE_URL_YOLO}');
+ expect($result->value())->toBe('SERVICE_URL_YOLO');
+});
+
+test('replaceVariables handles truncated brace format', function () {
+ // When regex captures {VAR from a larger expression, no closing brace
+ $result = replaceVariables('{API_URL');
+ expect($result->value())->toBe('API_URL');
+});
diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php
new file mode 100644
index 000000000..303ff007d
--- /dev/null
+++ b/tests/Unit/ParseEnvFormatToArrayTest.php
@@ -0,0 +1,248 @@
+toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
+ $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
+ ]);
+});
+
+test('parseEnvFormatToArray preserves # in quoted values', function () {
+ $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with # hash', 'comment' => null],
+ 'KEY2' => ['value' => 'another # hash', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values correctly', function () {
+ $input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'quoted value', 'comment' => null],
+ 'KEY2' => ['value' => 'single quoted', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips comment lines', function () {
+ $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips empty lines', function () {
+ $input = "KEY1=value1\n\nKEY2=value2\n\n";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles values with equals signs', function () {
+ $input = 'KEY1=value=with=equals';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty values', function () {
+ $input = "KEY1=\nKEY2=value";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles complex real-world example', function () {
+ $input = <<<'ENV'
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432 #default postgres port
+DB_NAME="my_database"
+DB_PASSWORD='p@ssw0rd#123'
+
+# API Keys
+API_KEY=abc123 # Production key
+SECRET_KEY=xyz789
+ENV;
+
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DB_HOST' => ['value' => 'localhost', 'comment' => null],
+ 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
+ 'DB_NAME' => ['value' => 'my_database', 'comment' => null],
+ 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
+ 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the original bug scenario', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ // The value should be "22", not "22 #needed for now"
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
+ // And the comment should be extracted
+ expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
+});
+
+test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
+ $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'another value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
+ $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
+ $result = parseEnvFormatToArray($input);
+
+ // KEY1: no space before #, so entire value is kept
+ // KEY2: space before first #, so everything from first space+# is stripped
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
+ $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '#starts with hash', 'comment' => null],
+ 'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
+ 'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray trims whitespace before comments', function () {
+ $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
+ ]);
+ // Values should not have trailing spaces
+ expect($result['KEY1']['value'])->not->toEndWith(' ');
+ expect($result['KEY2']['value'])->not->toEndWith("\t");
+});
+
+test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
+ $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips comments with space before hash', function () {
+ $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
+ $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
+ 'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty comments', function () {
+ $input = "KEY1=value #\nKEY2=value # ";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts multi-word comments', function () {
+ $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
+ $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
+ 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
+ $input = 'ASD=asd #asdfgg';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
+ ]);
+
+ // Specifically verify the comment is extracted
+ expect($result['ASD']['value'])->toBe('asd');
+ expect($result['ASD']['comment'])->toBe('asdfgg');
+ expect($result['ASD']['comment'])->not->toBeNull();
+});