toContain('resource-heading-navbar'); } $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); expect($application) ->toContain('justify-start') ->toContain('xl:justify-end') ->toContain('xl:w-auto') ->toContain("@teleport('#resource-action-hud-slot')") ->not->toContain('xl:fixed') ->not->toContain('xl:top-14') ->toContain('xl:hidden') ->not->toContain('application-primary-tabs') ->not->toContain("typeof collapsed !== 'undefined'") ->not->toContain('Spacer: in-flow stand-in') ->not->toContain('hidden lg:block lg:h-12') ->not->toContain('border-l border-neutral-200 pl-1'); }); it('uses interactive status summaries in mobile resource headings', function () { $headings = [ resource_path('views/livewire/project/application/heading.blade.php') => '', resource_path('views/livewire/project/database/heading.blade.php') => '', resource_path('views/livewire/project/service/heading.blade.php') => '', ]; foreach ($headings as $path => $statusSummary) { $mobileHeading = str(file_get_contents($path)) ->after('
') ->before('
') ->toString(); expect($mobileHeading) ->toContain('flex min-w-0 flex-col items-start gap-2') ->toContain('min-w-0 max-w-full truncate') ->toContain($statusSummary) ->not->toContain('toContain('id="resource-action-hud-slot"'); foreach ($files as $path) { $contents = file_get_contents($path); expect($contents) ->toContain("@teleport('#resource-action-hud-slot')") ->not->toContain('xl:fixed xl:top-14 xl:right-4'); $desktopHud = str($contents)->after("@teleport('#resource-action-hud-slot')")->before('@endteleport')->toString(); expect($desktopHud) ->not->toContain('rounded-[10px]') ->not->toContain('border-neutral-200') ->not->toContain('bg-neutral-100') ->not->toContain('dark:bg-white/[0.035]'); } }); it('links the service header missing variables warning to environment variables', function () { $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); expect($heading) ->toContain("route('project.service.environment-variables'") ->toContain('Required variables missing') ->toContain('href="{{ $environmentVariablesUrl }}"'); }); it('places the account menu beside the desktop sidebar toggle while retaining it on mobile', function () { $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); $navbar = file_get_contents(resource_path('views/components/navbar.blade.php')); $accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php')); expect($layout) ->not->toContain("{{-- Right cluster --}}\n ") ->toContain('') ->toContain('flex grow min-w-0 flex-col overflow-visible'); expect(substr_count($layout, ''))->toBe(1); expect($navbar) ->toContain('') ->toContain('Toggle sidebar') ->toContain("collapsed ? 'flex-col-reverse justify-center'"); expect($accountMenu) ->toContain("'sidebar' => false") ->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar"); }); it('keeps advanced operations in a dedicated Advanced dropdown', function () { $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $applicationAdvanced = file_get_contents(resource_path('views/components/applications/advanced.blade.php')); $serviceAdvanced = file_get_contents(resource_path('views/components/services/advanced.blade.php')); $links = file_get_contents(resource_path('views/components/applications/links.blade.php')); $applicationDesktop = str($application)->after('resource-heading-actions flex')->toString(); $serviceDesktop = str($service)->after('resource-heading-actions flex')->toString(); expect($applicationDesktop) ->toContain('application-desktop-actions') ->toContain('Deploy (without cache)') ->not->toContain('not->toContain('resource-heading-overflow-separator') ->not->toContain('Force deploy without cache') ->and($serviceDesktop) ->toContain('service-desktop-actions') ->toContain('Force Deploy') ->toContain('Force Cleanup Containers') ->toContain('Restart (pull latest)') ->not->toContain('resource-heading-overflow-separator') ->not->toContain('Pull Latest Images & Restart'); expect(strpos($applicationDesktop, 'toBeLessThan(strpos($applicationDesktop, 'application-desktop-actions')); expect(strpos($serviceDesktop, 'toBeLessThan(strpos($serviceDesktop, 'service-desktop-actions')); expect($applicationAdvanced) ->toContain('Advanced') ->toContain('name="grid"') ->not->toContain('Force deploy without cache') ->and($serviceAdvanced) ->toContain('Advanced') ->toContain('name="grid"') ->not->toContain('Pull Latest Images & Restart') ->not->toContain('Pull latest and restart') ->toContain('Force Restart') ->toContain('Force Deploy') ->toContain('Force Cleanup Containers') ->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth") ->and($links)->toContain('listbox-option justify-start! gap-2.5!') ->and($links)->not->toContain('md:left-0 md:right-auto'); // Desktop layer uses exactly one unified pill bar (mobile section may still use its own pill). expect(substr_count($application, 'resource-heading-navbar'))->toBe(1); }); it('groups desktop application lifecycle controls in one Actions dropdown', function () { $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->toString(); expect($desktop) ->toContain('application-desktop-actions') ->toContain('Actions') ->toContain('listbox-panel top-full! right-0! left-auto!') ->toContain('listbox-option') ->toContain('Deploy') ->toContain('Restart') ->toContain('Stop') ->not->toContain('not->toContain('toContain('resourceActionsOpen: false') ->toContain("'z-[1000]': resourceActionsOpen") ->toContain('@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"'); foreach ($dropdowns as $dropdown) { expect(file_get_contents($dropdown)) ->toContain("\$dispatch('resource-actions-toggled', { open })"); } }); it('groups service restart options in the Actions dropdown', function () { $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->toString(); expect($desktop) ->toContain('service-desktop-actions') ->toContain('Restart') ->toContain('Restart (pull latest)') ->toContain("\$wire.dispatch('pullAndRestartEvent')") ->not->toContain('Pull Latest Images & Restart') ->not->toContain('before("@teleport('#resource-action-hud-slot')")->toString(); expect($mobile) ->toContain('Restart current version') ->toContain('Pull latest and restart') ->not->toContain('Pull Latest Images & Restart'); }); it('orders service restart actions before stop and advanced operations', function () { $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $actions = str($heading)->after('id="service-desktop-actions"')->before('@else\n toString(); expect(strpos($actions, 'Restart\n')) ->toBeLessThan(strpos($actions, 'Restart (pull latest)')) ->and(strpos($actions, 'Restart (pull latest)')) ->toBeLessThan(strpos($actions, 'Stop')); }); it('groups application lifecycle options in an Actions dropdown', function () { $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->toString(); $trigger = str($desktop)->after('id="application-desktop-actions"')->before('
toString(); expect($desktop) ->toContain('id="application-desktop-actions"') ->toContain('Actions') ->toContain('Deploy') ->toContain('Deploy (without cache)') ->toContain('force_deploy_without_cache') ->toContain('deploy(true)') ->and($trigger) ->toContain('button button-highlighted') ->not->toContain('play-circle'); $mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString(); expect($mobile) ->toContain('Deploy (without cache)') ->not->toContain('Force deploy without cache'); }); it('places the state-aware no-cache action immediately after deploy or redeploy', function () { $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $actions = str($heading)->after('id="application-desktop-actions"')->before('@endteleport')->toString(); expect($actions) ->toContain("str(\$application->status)->startsWith('running') ? 'Redeploy (without cache)' : 'Deploy (without cache)'") ->toContain("str(\$application->status)->startsWith('running') ? 'force_deploy_without_cache' : 'deploy(true)'"); expect(strpos($actions, 'wire:click="deploy"')) ->toBeLessThan(strpos($actions, 'Redeploy (without cache)')) ->and(strpos($actions, 'Redeploy (without cache)')) ->toBeLessThan(strpos($actions, 'Restart')); }); it('uses the shared Coollabs gradient for primary resource actions in the desktop header', function () { $css = file_get_contents(resource_path('css/app.css')); expect($css) ->toContain('.application-heading-actions .button-highlighted') ->toContain('@apply button-highlighted;'); }); it('uses iconless highlighted Actions dropdowns for service and proxy lifecycle controls', function () { foreach ([ resource_path('views/livewire/project/service/heading.blade.php') => 'service-desktop-actions', resource_path('views/livewire/server/navbar.blade.php') => 'server-desktop-actions', ] as $path => $id) { $heading = file_get_contents($path); $trigger = str($heading)->after("id=\"{$id}\"")->before('
toString(); expect($trigger) ->toContain('button button-highlighted') ->toContain('Actions') ->not->toContain('play-circle'); } }); it('places the Traefik dashboard last in the server Actions menu', function () { $navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php')); $actions = str($navbar)->after('id="server-desktop-actions"')->before('@endcan')->toString(); expect(strpos($actions, 'Refresh Proxy Status')) ->toBeLessThan(strpos($actions, 'Traefik Dashboard')); }); it('keeps database lifecycle controls direct and highlights the primary action', function () { $heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->before('@endteleport')->toString(); expect($desktop) ->toContain('Restart') ->toContain('Stop') ->toContain('button button-highlighted') ->not->toContain('not->toContain('toContain("@teleport('#configuration-warning-hud-slot')") ->toContain("@teleport('#configuration-warning-hud-slot-mobile')") ->toContain('') ->not->toContain('and($warning) ->toContain('aria-label="Configuration changes not applied"') ->toContain('') ->toContain('The latest configuration has not been applied') ->toContain('fixed top-14 left-1/2') ->toContain('-translate-x-1/2') ->toContain('lg:absolute lg:top-full lg:right-0 lg:left-auto') ->toContain('lg:translate-x-0') ->toContain('@click.outside="open = false"') ->toContain('@keydown.escape.window="open = false"') ->toContain("\$dispatch('open-configuration-diff')"); }); it('renders only one configuration warning on database runtime logs', function () { $logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php')); $databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); expect($logs) ->not->toContain(" \n\n @if (\$type === 'application')") ->and($databaseHeading) ->toContain(''); }); it('renders only one configuration warning on database and service terminal pages', function () { $terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php')); expect($terminal) ->not->toContain("@elseif (\$type === 'database')\n ") ->not->toContain("@elseif (\$type === 'service')\n "); }); it('renders only one configuration warning on the service volume backups page', function () { $volumeBackups = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); expect($volumeBackups) ->not->toContain('') ->toContain('not->toContain("'label' => 'Backups'") ->and($configuration)->toContain('and($backup)->toContain('not->toContain("'label' => 'Terminal'") ->not->toContain("'label' => 'Deployment'") ->not->toContain("'label' => 'Runtime'") ->and($sidebar) ->toContain("'label' => 'Terminal'") ->toContain("'label' => 'Deployment Logs'") ->toContain("'label' => 'Runtime Logs'") ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']") ->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'"); }); it('groups application automation pages separately from build and deploy', function () { $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); expect($sidebar) ->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']") ->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']") ->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']") ->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'"); }); it('centers the rollback image loading state across the card', function () { $rollback = file_get_contents(resource_path('views/livewire/project/application/rollback.blade.php')); expect($rollback) ->toContain('class="w-full" wire:target="loadImages" wire:loading') ->toContain('flex items-center justify-center'); }); it('shows pull request loading feedback only on the action button', function () { $previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php')); expect($previews) ->toContain('wire:click="load_prs"') ->not->toContain('wire:loading.remove wire:target="load_prs"') ->not->toContain('wire:loading wire:target="load_prs"') ->not->toContain('Loading pull requests…'); }); it('shows loading feedback while a service deployment starts', function () { $service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); expect($service) ->toContain('deploying: false') ->toContain('@service-deploy-finished.window="deploying = false"') ->toContain('toContain("window.dispatchEvent(new CustomEvent('service-deploy-finished'))"); }); it('uses neutral icons for non-destructive resource actions', function () { foreach (['application', 'service', 'database'] as $resource) { $heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php")); expect($heading) ->not->toContain('class="size-3.5 text-orange-500') ->not->toContain('class="size-3.5 text-warning"') ->not->toContain('class="size-4 text-warning"') ->toContain('name="stop-circle"'); } }); it('uses a circular stop icon in application and service action menus', function () { $icons = file_get_contents(resource_path('views/components/reicon.blade.php')); expect($icons) ->toContain("'stop-circle' =>") ->toContain('toContain('toContain('name="stop-circle" class="size-3.5 text-error"') ->not->toContain('name="stop" class="size-3.5 text-error"'); } }); it('allows the shared empty state to control the storage backups background', function () { $backups = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')); expect($backups) ->toContain("'application-settings-section-body w-full'") ->toContain("'is-flush' => \$backups->isNotEmpty()") ->not->toContain('application-settings-section-body is-flush w-full bg-transparent!'); }); it('keeps the application settings sidebar below the fixed header while scrolling', function () { $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); $css = file_get_contents(resource_path('css/app.css')); expect($sidebar)->toContain('application-settings-navigation') ->and($css)->toContain('.application-settings-workspace > .application-settings-navigation') ->and($css)->toContain('top: 4rem;') ->and($css)->toContain('max-height: calc(100dvh - 5rem);'); }); it('builds application sidebar routes independently of the current request route', function () { $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); expect($sidebar) ->not->toContain('get_route_parameters()') ->toContain("'project_uuid' => \$application->environment->project->uuid") ->toContain("'environment_uuid' => \$application->environment->uuid") ->toContain("'application_uuid' => \$application->uuid"); }); it('keeps the deployment log sidebar fixed in the layout without a top gap', function () { $deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); $css = file_get_contents(resource_path('css/app.css')); expect($deployment)->toContain(':flush="true"') ->and($css)->toContain('.application-settings-navigation.is-flush') ->and($css)->toContain('position: static;') ->and($css)->toContain('overflow: visible;'); }); it('uses the same mobile heading gap on deployment pages as application settings', function () { $configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php')); $deploymentIndex = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php')); $deploymentShow = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); expect($configuration)->toContain('application-settings-workspace mt-4') ->and($deploymentIndex)->toContain("'mt-4 max-w-[1180px] lg:mt-0' => ! \$embedded") ->and($deploymentShow)->toContain('application-settings-workspace mt-4') ->toContain('lg:mt-0'); }); it('removes desktop top spacing from the deployment log viewer', function () { $deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); expect($deployment)->toContain("'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'"); }); it('uses a distinct runtime log icon in the sidebar', function () { $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); expect($sidebar)->toContain("'Runtime Logs' => 'unordered-list'"); }); it('shows deployment history above the selected deployment logs', function () { $indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php')); $indexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php')); $showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); expect($indexClass) ->toContain('public bool $embedded = false;') ->toContain('public ?string $selectedDeploymentUuid = null;') ->and($indexView) ->toContain("'data-table-row-active' => \$selectedDeploymentUuid") ->and($showView) ->toContain('toContain(':selected-deployment-uuid="$deployment_uuid"'); }); it('fits the combined deployment history and logs within the desktop viewport', function () { $indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php')); $showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); expect($indexClass)->toContain('$this->defaultTake = 3;') ->and($showView) ->toContain('xl:h-[calc(100dvh-7.5rem)]') ->toContain('xl:overflow-hidden') ->toContain('xl:flex-1'); }); it('uses only the healthcheck toggle action to communicate enabled state', function () { $healthcheck = file_get_contents(resource_path('views/livewire/project/shared/health-checks.blade.php')); expect($healthcheck) ->not->toContain('toContain('buttonTitle="Enable"') ->toContain('Disable'); }); it('keeps Links dropdowns outside the scrollable tabs strip', function () { $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $css = file_get_contents(resource_path('css/app.css')); $tabsComponent = file_get_contents(resource_path('views/components/resource-heading-tabs.blade.php')); // Application Links are standalone on mobile and next to actions on desktop. expect($application) ->not->toContain('toContain('resource-heading-menus') ->toContain('not->toContain('toContain('resource-heading-menus') ->toContain('toContain('.resource-heading-tabs::-webkit-scrollbar') ->toContain('scrollbar-width: none') ->toContain('.resource-heading-tabs-control') ->toContain('.resource-heading-tabs-control-icon'); // Kumo-style overflow chevrons live on the shared tabs component. expect($tabsComponent) ->toContain('Scroll tabs left') ->toContain('Scroll tabs right') ->toContain('scrollByDir') ->toContain('canStart') ->toContain('canEnd') ->toContain('resource-heading-tabs-control is-start') ->toContain('resource-heading-tabs-control is-end') ->toContain('scrollActiveIntoView') ->toContain('findActiveTab') ->toContain('livewire:navigated'); }); it('shows an icon in application and service Links controls', function () { $applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php')); $serviceLinks = file_get_contents(resource_path('views/components/services/links.blade.php')); $serviceLinksComponent = file_get_contents(app_path('View/Components/Services/Links.php')); expect($applicationLinks)->toContain("@props(['application', 'fullWidth' => false, 'compact' => false])") ->toContain('and($serviceLinksComponent)->toContain('public bool $fullWidth = false') ->and($serviceLinks) ->toContain('and($applicationLinks)->not->toContain('and($serviceLinks)->not->toContain('toContain('Production') ->toContain('PR #{{ data_get($preview, \'pull_request_id\') }}') ->not->toContain("PR{{ data_get(\$preview, 'pull_request_id') }} |"); }); it('shows application Links as a compact badge beside the mobile status', function () { $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php')); $mobileApplicationTitle = str($application)->between('class="mb-3 w-full xl:hidden"', '
')->toString(); $mobileApplicationActions = str($application)->between('
', '