Merge remote-tracking branch 'origin/next' into celld-one-click-service

This commit is contained in:
Andras Bacsai committed 2026-08-07 22:46:22 +02:00
commit 11dc7cee7a
40 files changed
+801 -96

No files matched your search

@@ -301,7 +301,7 @@ YAML,
$parsedCompose = applicationParser($application);
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=${1}://www.${2}')))->toBeTrue();
expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=$${1}://www.$${2}')))->toBeTrue();
});
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
@@ -13,6 +13,10 @@ it('uses the large modal treatment for the compose editor', function () {
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('@compose-validate-finished.window="validating = false"')
->toContain('@click="validating = true; $dispatch(\'compose-validate\')"')
->toContain('x-bind:disabled="validating"')
->toContain('<x-loading-on-button x-show="validating" x-cloak />')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
@@ -36,6 +40,7 @@ it('renders the compose editor with clear guidance settings and actions', functi
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
@@ -182,6 +182,34 @@ YAML,
->toBe(['API_TOKEN']);
});
it('shows a Compose-defined value as read-only when a managed variable has the same key', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
- API_TOKEN=from-compose
YAML,
]);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'from-environment-tab',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariablePageRows)
->toHaveCount(1)
->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('hardcoded')
->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose');
});
it('searches service environment variables without requiring preview variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
+13
View File
@@ -57,9 +57,22 @@ it('uses login as primary action on session expired page', function () {
->toContain('/login')
->toContain('Back to login')
->toContain('Using a reverse proxy or Cloudflare Tunnel?')
->toContain('x-data="{ open: false }"')
->toContain('x-on:click="open = !open"')
->toContain('livewire.js')
->not->toContain('>Dashboard</');
});
it('renders contact support as a button', function () {
$exception = new HttpException(404, 'Not found');
$html = view('errors.404', ['exception' => $exception])->render();
expect($html)
->toContain('href="'.config('constants.urls.contact').'"')
->toMatch('/<button[^>]*>\s*Contact support/s');
});
it('shows purified exception message on 500 page', function () {
$exception = new RuntimeException('Database connection failed');
@@ -13,6 +13,26 @@ it('keeps the volume backup executions table horizontally scrollable on mobile',
->not->toContain('.data-table-header.volume-backup-executions-grid');
});
it('uses compact icon actions for volume backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'));
expect($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('keeps storage backup schedule tables horizontally scrollable on mobile', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($applicationView)->toContain('class="data-table w-full overflow-x-auto"')
->and($serviceView)->toContain('class="data-table w-full overflow-x-auto"')
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
});
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
+131
View File
@@ -0,0 +1,131 @@
<?php
use App\Livewire\Profile\Index;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Services\AvatarStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::create([
'id' => 0,
'avatar_storage_type' => 'local',
]));
});
it('compresses and stores an uploaded profile picture on the configured local storage', function () {
Storage::fake('local');
$user = User::factory()->create(['name' => 'Test User']);
$this->actingAs($user);
Livewire::test(Index::class)
->set('avatar', UploadedFile::fake()->image('profile.png', 1200, 900))
->call('uploadAvatar')
->assertHasNoErrors();
$user->refresh();
expect($user->avatar_path)
->toBe("avatars/{$user->id}/avatar.jpg")
->and($user->avatar_storage_type)->toBe('local')
->and($user->avatar_s3_storage_id)->toBeNull();
Storage::disk('local')->assertExists($user->avatar_path);
$image = getimagesizefromstring(Storage::disk('local')->get($user->avatar_path));
expect($image[0])->toBeLessThanOrEqual(256)
->and($image[1])->toBeLessThanOrEqual(256)
->and($image['mime'])->toBe('image/jpeg')
->and(Storage::disk('local')->size($user->avatar_path))->toBeLessThan(100_000);
});
it('stores an already compressed browser JPEG without server image extensions', function () {
Storage::fake('local');
$user = User::factory()->create(['name' => 'Test User']);
$contents = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gODAK/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8AAEQgAAgACAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+qaKKKAP/9k=');
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, $contents);
$upload = new UploadedFile($path, 'avatar.jpg', 'image/jpeg', null, true);
app(AvatarStorageService::class)->store($user, $upload);
expect(Storage::disk('local')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents);
});
it('serves the authenticated users profile picture', function () {
Storage::fake('local');
$user = User::factory()->create([
'name' => 'Test User',
'avatar_path' => 'avatars/1/avatar.jpg',
'avatar_storage_type' => 'local',
]);
Storage::disk('local')->put($user->avatar_path, 'avatar-content');
$this->withoutMiddleware()->actingAs($user)
->get(route('profile.avatar'))
->assertSuccessful()
->assertHeader('content-type', 'image/jpeg');
});
it('removes the current profile picture', function () {
Storage::fake('local');
$user = User::factory()->create([
'name' => 'Test User',
'avatar_path' => 'avatars/1/avatar.jpg',
'avatar_storage_type' => 'local',
]);
Storage::disk('local')->put($user->avatar_path, 'avatar-content');
$this->actingAs($user);
Livewire::test(Index::class)
->call('removeAvatar')
->assertHasNoErrors();
expect($user->refresh()->avatar_path)->toBeNull();
Storage::disk('local')->assertMissing('avatars/1/avatar.jpg');
});
it('falls back cleanly when the avatars S3 storage no longer exists', function () {
$user = User::factory()->create([
'name' => 'Test User',
'avatar_path' => 'avatars/1/avatar.webp',
'avatar_storage_type' => 's3',
'avatar_s3_storage_id' => null,
]);
$this->withoutMiddleware()->actingAs($user)
->get(route('profile.avatar'))
->assertNotFound();
});
it('renders the profile upload and user menu avatar', function () {
$profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($profile)
->toContain("this.\$wire.upload('avatar', compressed")
->toContain('canvas.toBlob')
->toContain('wire:click="uploadAvatar"')
->and($menu)
->toContain("route('profile.avatar',");
});
it('offers runtime local or existing S3 profile picture storage', function () {
$component = file_get_contents(app_path('Livewire/Settings/Advanced.php'));
$view = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php'));
expect($component)
->toContain("['value' => 'local', 'label' => 'Local storage']")
->toContain("'value' => 's3:'.\$storage->id")
->toContain('->whereTeamId(0)')
->toContain("->where('is_usable', true)")
->and($view)
->toContain('id="avatar_storage"')
->toContain('Use S3 for multi-instance or cloud deployments');
});
@@ -144,6 +144,17 @@ it('shows connection progress in the terminal body instead of the header', funct
->toContain("this.starting = this.\$el.dataset.autoStart === 'true';");
});
it('starts the global terminal only once when a target is selected', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$component = file_get_contents(app_path('Livewire/Terminal/Index.php'));
expect($view)
->toContain("await \$wire.set('selected_uuid', target.value);")
->not->toContain('await $wire.connectToContainer();')
->and($component)
->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s');
});
it('uses the redesigned terminal canvas and controls on resource terminal pages', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
@@ -108,7 +108,7 @@ test('settings advanced unsaved bar scopes dirty tracking away from instantSave
expect($contents)
->toContain('x-unsaved-bar')
->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost"')
->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key"')
->toContain('onChange="instantSave"')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
+9
View File
@@ -23,6 +23,15 @@ it('shows a centered themed target canvas before loading xterm', function () {
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
});
it('keeps the terminal target picker within the mobile canvas', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('data-terminal-target-picker="page"')
->toContain('flex max-h-full w-full max-w-lg flex-col')
->toContain('class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2"');
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
+9 -2
View File
@@ -25,8 +25,15 @@ it('changes appearance from a submenu instead of navigating to a separate page',
expect($menu)
->toContain('appearanceOpen: false')
->toContain('@click.outside="open = false; appearanceOpen = false"')
->toContain("theme: localStorage.getItem('theme') || 'dark'")
->toContain('setTheme(type)')
->toContain("theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark')")
->toContain('setTheme(type, closeMenu = true)')
->toContain("this.setTheme('custom', false)")
->not->toContain('@change="appearanceOpen = false; open = false"')
->toContain('this.appearanceOpen = false;')
->toContain('this.open = false;')
->toContain('<template x-if="open">')
->not->toContain('x-show.important="open"')
->not->toContain('<div x-show="open" x-cloak x-transition.opacity.duration.120ms')
->toContain("['value' => 'light', 'label' => 'Light'")
->toContain("['value' => 'system', 'label' => 'System'")
->toContain("['value' => 'dark', 'label' => 'Dark'")