toContain('x-unsaved-bar') ->toContain('targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); test('unsaved bar component accepts optional wire:target list', function () { $path = resource_path('views/components/unsaved-bar.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain("'targets' => null") ->toContain('wire:target="{{ $targets }}"'); }); test('unsaved bar delays show and hides while loading to avoid instant-save flash', function () { $path = resource_path('views/components/unsaved-bar.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('wire:dirty.class="is-dirty"') ->toContain('wire:loading.class="is-saving"') ->toContain('[&.is-dirty]:delay-300') ->toContain('delay-0'); }); test('unsaved bar uses a stable transition for its entrance', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('scale-95') ->toContain('transition-[opacity,transform,scale]') ->toContain('duration-300') ->toContain('ease-[cubic-bezier(0.16,1,0.3,1)]') ->toContain('[&.is-dirty]:scale-100') ->not->toContain('[&.is-dirty]:animate-in'); }); test('unsaved bar transitions out without restarting its entrance animation', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('[&.is-saving]:translate-y-6') ->toContain('[&.is-saving]:scale-95') ->toContain('[&.is-saving]:opacity-0') ->toContain('[&.is-saving]:duration-200') ->toContain('[&.is-saving]:ease-in') ->toContain('[&.is-saving]:pointer-events-none') ->not->toContain('[&.is-saving]:animate-out'); }); test('unsaved bar saves with enter and shows the shortcut on the save button', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('@keydown.enter.window') ->toContain("classList.contains('is-dirty')") ->toContain('$wire.{{ $action }}()') ->toContain('toContain('Enter'); }); test('unsaved bar keyboard shortcut remains visible in light mode', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('border-coollabs/20 bg-coollabs/10') ->toContain('text-coollabs-200') ->toContain('dark:border-white/20 dark:bg-white/10 dark:text-white/75'); }); test('unsaved bar uses a light surface in light mode', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('border-neutral-200 bg-white') ->toContain('text-neutral-800 dark:text-fg') ->toContain('bg-neutral-100') ->toContain('dark:bg-white/[0.07]'); }); test('unsaved bar stays above floating notifications so save actions remain accessible', function () { $unsavedBar = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); $popup = file_get_contents(resource_path('views/components/popup-small.blade.php')); expect($unsavedBar) ->toContain('z-[1000]') ->and($popup)->toContain('z-999'); }); test('unsaved bar stays above the mobile keyboard', function () { $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) ->toContain('window.visualViewport') ->toContain('keyboardInset') ->toContain('--keyboard-inset') ->toContain('removeEventListener'); }); test('settings general unsaved bar excludes auto-saving instance timezone', function () { $path = resource_path('views/livewire/settings/index.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('targets="fqdn,instance_name,public_ipv4,public_ipv6,dev_helper_version"') ->toContain('id="instance_timezone"') ->toContain('onChange="submit"') ->toContain('x-forms.searchable-listbox') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); test('settings general instance timezone uses searchable listbox for label alignment with name', function () { $path = resource_path('views/livewire/settings/index.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('x-forms.searchable-listbox id="instance_timezone"') ->toContain('id="instance_name"') ->not->toContain('selectTimezone(timezone)'); }); test('sentinel custom docker image x-init only sets wire when a value exists', function () { $path = resource_path('views/livewire/server/sentinel.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain("x-init=\"if (customImage) { \$wire.set('sentinelCustomDockerImage', customImage) }\"") ->not->toContain("x-init=\"\$wire.set('sentinelCustomDockerImage', customImage)\""); }); /** * Instant-save listboxes (e.g. MCP server) entangle + call instantSave. Until the * round-trip finishes, the component is dirty — so an unscoped unsaved bar flashes. * Pages that mix instantSave and explicit-save fields must pass targets. */ test('settings advanced unsaved bar scopes dirty tracking away from instantSave fields', function () { $path = resource_path('views/livewire/settings/advanced.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('x-unsaved-bar') ->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*\/>/'); }); test('settings updates unsaved bar scopes dirty tracking away from instantSave fields', function () { $path = resource_path('views/livewire/settings/updates.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('targets="update_check_frequency,auto_update_frequency"') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); test('docker cleanup unsaved bar scopes dirty tracking away from instantSave fields', function () { $path = resource_path('views/livewire/server/docker-cleanup.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('targets="dockerCleanupFrequency,dockerCleanupThreshold"') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); test('application advanced gpu unsaved bar scopes dirty tracking to gpu fields only', function () { $path = resource_path('views/livewire/project/application/advanced.blade.php'); $contents = file_get_contents($path); expect($contents) ->toContain('targets="gpuDriver,gpuCount,gpuDeviceIds,gpuOptions"'); });