diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index aeaa2776b1..0fc0fd253a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -43,17 +43,20 @@ work extends shared components instead of creating new local variants. 21. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx` 22. `frontend-modern/src/components/Settings/useSSOProvidersState.ts` 23. `frontend-modern/src/components/Settings/ssoProvidersModel.ts` -24. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` -25. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` -26. `tests/integration/tests/15-settings-shell-consistency.spec.ts` -27. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` -28. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` -29. `frontend-modern/src/features/` -30. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` -31. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` -32. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` -33. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` -34. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` +24. `frontend-modern/src/components/Settings/CopyCommandBlock.tsx` +25. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx` +26. `frontend-modern/src/components/Settings/updatesSettingsModel.ts` +27. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` +28. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` +29. `tests/integration/tests/15-settings-shell-consistency.spec.ts` +30. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` +31. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` +32. `frontend-modern/src/features/` +33. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` +34. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` +35. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` +36. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` +37. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` ## Shared Boundaries @@ -123,6 +126,16 @@ license/paywall lifecycle, persisted filters, verification flow, and audit-log fetch orchestration. The shell must not re-accumulate localStorage or API runtime logic inline. +The updates settings surface now follows the same presentation-owner rule. +`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the +top-level settings shell, while +`frontend-modern/src/components/Settings/UpdateInstallGuide.tsx`, +`frontend-modern/src/components/Settings/CopyCommandBlock.tsx`, and +`frontend-modern/src/components/Settings/updatesSettingsModel.ts` own the +deployment-specific install guide, copy-command block, and update-channel/install +model data. The panel shell must not rebuild copy-to-clipboard command cards or +deployment instruction trees inline. + General settings segmented selectors for theme preference and temperature unit must now also route through the shared `FilterButtonGroup` primitive instead of maintaining local button-group styling forks inside diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index c282fd8c54..6852132130 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1647,6 +1647,7 @@ "frontend-modern/src/components/Settings/APIAccessPanel.tsx", "frontend-modern/src/components/Settings/AuditLogPanel.tsx", "frontend-modern/src/components/Settings/AuditWebhookPanel.tsx", + "frontend-modern/src/components/Settings/CopyCommandBlock.tsx", "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", "frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx", "frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx", @@ -1658,6 +1659,8 @@ "frontend-modern/src/components/Settings/settingsPanelRegistry.ts", "frontend-modern/src/components/Settings/ssoProvidersModel.ts", "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx", + "frontend-modern/src/components/Settings/UpdateInstallGuide.tsx", + "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts", @@ -1705,6 +1708,7 @@ "frontend-modern/src/components/Settings/APIAccessPanel.tsx", "frontend-modern/src/components/Settings/AuditLogPanel.tsx", "frontend-modern/src/components/Settings/AuditWebhookPanel.tsx", + "frontend-modern/src/components/Settings/CopyCommandBlock.tsx", "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", "frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx", "frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx", @@ -1716,6 +1720,8 @@ "frontend-modern/src/components/Settings/settingsPanelRegistry.ts", "frontend-modern/src/components/Settings/ssoProvidersModel.ts", "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx", + "frontend-modern/src/components/Settings/UpdateInstallGuide.tsx", + "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts" diff --git a/frontend-modern/src/components/Settings/CopyCommandBlock.tsx b/frontend-modern/src/components/Settings/CopyCommandBlock.tsx new file mode 100644 index 0000000000..b5ae36f572 --- /dev/null +++ b/frontend-modern/src/components/Settings/CopyCommandBlock.tsx @@ -0,0 +1,42 @@ +import Copy from 'lucide-solid/icons/copy'; + +interface CopyCommandBlockProps { + command: string; + onCopy?: (command: string) => void | Promise; + containerClass?: string; + codeClass?: string; + buttonClass?: string; +} + +const DEFAULT_CONTAINER_CLASS = 'relative group'; +const DEFAULT_CODE_CLASS = + 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content'; +const DEFAULT_BUTTON_CLASS = + 'absolute right-2 top-2 inline-flex min-h-9 min-w-9 items-center justify-center rounded bg-surface-hover p-2 text-slate-300 opacity-70 transition-opacity hover:bg-slate-600 hover:opacity-100'; + +export function CopyCommandBlock(props: CopyCommandBlockProps) { + const handleCopy = () => { + if (props.onCopy) { + void props.onCopy(props.command); + return; + } + void navigator.clipboard.writeText(props.command); + }; + + return ( +
+ {props.command} + +
+ ); +} + +export default CopyCommandBlock; diff --git a/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx b/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx new file mode 100644 index 0000000000..e932fca227 --- /dev/null +++ b/frontend-modern/src/components/Settings/UpdateInstallGuide.tsx @@ -0,0 +1,204 @@ +import { Component, For, Show } from 'solid-js'; +import Download from 'lucide-solid/icons/download'; +import type { UpdateInfo, UpdatePlan, VersionInfo } from '@/api/updates'; +import { CopyCommandBlock } from '@/components/Settings/CopyCommandBlock'; +import { + buildIdleDockerComposeCommand, + buildIdleDockerUpdateCommand, + buildUpdateInstallGuide, + type UpdateInstallStep, +} from '@/components/Settings/updatesSettingsModel'; + +interface UpdateInstallGuideProps { + versionInfo: VersionInfo | null; + updateInfo: UpdateInfo | null; + updatePlan: UpdatePlan | null; + isInstalling: boolean; + dockerImageTag: string; + systemdDownloadCommand: string; + onInstallUpdate: () => void; +} + +function StepIndex(props: { index: number }) { + return ( + + {props.index} + + ); +} + +function InstallStep(props: { step: UpdateInstallStep; index: number }) { + return ( +
+ +
+ + {props.step.title} +
+
+ + + + +

{props.step.note}

+
+
+ ); +} + +export const UpdateInstallGuide: Component = (props) => { + const guide = () => + buildUpdateInstallGuide( + props.versionInfo, + props.updateInfo, + props.updatePlan, + props.dockerImageTag, + props.systemdDownloadCommand, + ); + + return ( + <> + +
+
+ + + +

Docker Installation

+
+

+ Updates are managed through Docker. Use these commands to check for and apply updates: +

+
+ +

+ Or with Docker Compose:{' '} + + {buildIdleDockerComposeCommand()} + +

+
+
+
+ + +
+

+ Built from source: Pull the latest code from git and rebuild to + update. +

+
+
+ + +
+

{props.updateInfo?.warning}

+
+
+ + +
+
+
+
+
+ + + +
+
+

+ {guide()!.headerTitle} +

+

{guide()!.headerSummary}

+
+
+ + + + +
+
+ +
+
{guide()!.introText}
+ + + {(step, index) => } + +
+ + +
+
+ + + + + View Release Notes + +
+                  {props.updateInfo?.releaseNotes}
+                
+
+
+
+
+
+ + ); +}; + +export default UpdateInstallGuide; diff --git a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx index 37092c1b33..8eabbc06b6 100644 --- a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx @@ -9,9 +9,13 @@ import RefreshCw from 'lucide-solid/icons/refresh-cw'; import CheckCircle from 'lucide-solid/icons/check-circle'; import ArrowRight from 'lucide-solid/icons/arrow-right'; import Package from 'lucide-solid/icons/package'; -import Download from 'lucide-solid/icons/download'; import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates'; import { buildDockerImageTag, buildLinuxAmd64DownloadCommand } from '@/components/updateVersion'; +import { UpdateInstallGuide } from '@/components/Settings/UpdateInstallGuide'; +import { + getUpdateChannelCardOptions, + type UpdateChannelOptionValue, +} from '@/components/Settings/updatesSettingsModel'; import { getUpdateAvailabilityHeading, getUpdateBuildBadges, @@ -45,52 +49,40 @@ export const UpdatesSettingsPanel: Component = (props const systemdDownloadCommand = () => buildLinuxAmd64DownloadCommand(latestVersion()); const isPreviewChannel = () => props.updateChannel() === 'rc'; const autoUpdateLocked = () => Boolean(props.versionInfo()?.isSourceBuild || isPreviewChannel()); - const updateChannelOptions = (): SelectionCardOption<'stable' | 'rc'>[] => [ - { - value: 'stable', - title: 'Stable', - description: 'Production-ready releases for paid and self-hosted environments', - tone: 'success', - disabled: props.versionInfo()?.isSourceBuild, - icon: ({ active }) => ( - - - - ), - }, - { - value: 'rc', - title: 'Release Candidate', - description: 'Preview builds for staging, internal validation, and opt-in testers', - tone: 'accent', - disabled: props.versionInfo()?.isSourceBuild, - icon: ({ active }) => ( - - - - ), - }, - ]; + const updateChannelOptions = (): SelectionCardOption[] => + getUpdateChannelCardOptions(props.versionInfo()).map((option) => ({ + ...option, + icon: (iconProps) => + option.value === 'stable' ? ( + + + + ) : ( + + + + ), + })); return ( = (props - {/* Docker installation notice - enhanced with copy-able commands */} - -
-
- - - -

- Docker Installation -

-
-

- Updates are managed through Docker. Use these commands to check for and apply - updates: -

-
-
- - docker pull rcourtman/pulse:latest && docker restart pulse - - -
-

- Or with Docker Compose:{' '} - - docker-compose pull && docker-compose up -d - -

-
-
-
- - {/* Source build notice */} - -
-

- Built from source: Pull the latest code from git and rebuild to - update. -

-
-
- - {/* Warning message */} - -
-

- {props.updateInfo()?.warning} -

-
-
- - {/* Update available */} - -
- {/* Header */} -
-
-
-
- - - -
-
-

- Update Available -

-

- Version {props.updateInfo()?.latestVersion} is ready to install -

-
-
- - {/* Automated Install Button */} - - - -
-
- - {/* Installation Steps */} -
- {/* Manual Steps Header */} - -
- Follow these steps to update manually: -
-
- - -
- Click "Install Update" above for automatic installation, or update manually: -
-
- - {/* ProxmoxVE LXC Installation */} - -
-
- - 1 - - Open your Pulse LXC console -
-
- - 2 - - Run the update command: -
-
- - update - - -
-

- The script will automatically download and install the latest version. -

-
-
- - {/* Docker Installation */} - -
-
- - 1 - - Pull the latest image -
-
- - docker pull rcourtman/pulse:{dockerImageTag()} - - -
- -
- - 2 - - Restart the container -
-
- - docker restart pulse - - -
-

- Or use Docker Compose:{' '} - - docker-compose pull && docker-compose up -d - -

-
-
- - {/* Systemd/Manual Installation */} - -
-
- - 1 - - Stop the service -
-
- - sudo systemctl stop pulse - - -
- -
- - 2 - - Download and extract the new version -
-
- - {systemdDownloadCommand()} - - -
- -
- - 3 - - Start the service -
-
- - sudo systemctl start pulse - - -
-
-
- - {/* Development Installation */} - -
-
- - 1 - - Pull the latest changes -
-
- - git pull origin main - - -
- -
- - 2 - - Rebuild and restart -
-
- - make build && make run - - -
-
-
-
- - {/* Release notes footer */} - -
-
- - - - - View Release Notes - -
-                        {props.updateInfo()?.releaseNotes}
-                      
-
-
-
-
-
+ diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index debc275d25..49fed3d7a2 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -21,6 +21,9 @@ import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSect import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw'; import aiSettingsModelSource from '../aiSettingsModel.ts?raw'; import networkSettingsPanelSource from '../NetworkSettingsPanel.tsx?raw'; +import copyCommandBlockSource from '../CopyCommandBlock.tsx?raw'; +import updateInstallGuideSource from '../UpdateInstallGuide.tsx?raw'; +import updatesSettingsModelSource from '../updatesSettingsModel.ts?raw'; import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw'; import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw'; import relaySettingsPanelSource from '../RelaySettingsPanel.tsx?raw'; @@ -61,6 +64,9 @@ const extractedModules = [ '../AIProviderConfigurationSection.tsx', '../AISettingsDialogs.tsx', '../aiSettingsModel.ts', + '../CopyCommandBlock.tsx', + '../UpdateInstallGuide.tsx', + '../updatesSettingsModel.ts', '../useSSOProvidersState.ts', '../ssoProvidersModel.ts', '../ProxmoxSettingsPanel.tsx', @@ -349,6 +355,20 @@ describe('Settings architecture guardrails', () => { expect(aiSettingsModelSource).toContain('export const AI_SETUP_PROVIDER_OPTIONS'); }); + it('keeps the updates settings shell behind extracted install-guide owners', () => { + expect(updatesSettingsPanelSource).toContain('@/components/Settings/UpdateInstallGuide'); + expect(updatesSettingsPanelSource).toContain('@/components/Settings/updatesSettingsModel'); + expect(updatesSettingsPanelSource).not.toContain("navigator.clipboard.writeText('update')"); + expect(updatesSettingsPanelSource).not.toContain("value: 'stable'"); + expect(updatesSettingsPanelSource).not.toContain("value: 'rc'"); + expect(updateInstallGuideSource).toContain('@/components/Settings/CopyCommandBlock'); + expect(updateInstallGuideSource).toContain('buildUpdateInstallGuide'); + expect(copyCommandBlockSource).toContain('export function CopyCommandBlock'); + expect(copyCommandBlockSource).toContain("aria-label=\"Copy to clipboard\""); + expect(updatesSettingsModelSource).toContain('export function getUpdateChannelCardOptions'); + expect(updatesSettingsModelSource).toContain('export function buildUpdateInstallGuide'); + }); + it('keeps the audit log shell behind an extracted runtime owner', () => { expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); expect(auditLogPanelSource).not.toContain('createLocalStorageStringSignal'); diff --git a/frontend-modern/src/components/Settings/updatesSettingsModel.ts b/frontend-modern/src/components/Settings/updatesSettingsModel.ts new file mode 100644 index 0000000000..9ce87fa34c --- /dev/null +++ b/frontend-modern/src/components/Settings/updatesSettingsModel.ts @@ -0,0 +1,149 @@ +import type { UpdateInfo, UpdatePlan, VersionInfo } from '@/api/updates'; + +export type UpdateChannelOptionValue = 'stable' | 'rc'; + +export interface UpdateChannelCardOption { + value: UpdateChannelOptionValue; + title: string; + description: string; + tone: 'success' | 'accent'; + disabled?: boolean; +} + +export interface UpdateInstallStep { + id: string; + title: string; + command?: string; + note?: string; + commandCodeClass?: string; +} + +export interface UpdateInstallGuide { + headerTitle: string; + headerSummary: string; + introText: string; + steps: UpdateInstallStep[]; +} + +export function getUpdateChannelCardOptions( + versionInfo?: Pick | null, +): UpdateChannelCardOption[] { + return [ + { + value: 'stable', + title: 'Stable', + description: 'Production-ready releases for paid and self-hosted environments', + tone: 'success', + disabled: versionInfo?.isSourceBuild, + }, + { + value: 'rc', + title: 'Release Candidate', + description: 'Preview builds for staging, internal validation, and opt-in testers', + tone: 'accent', + disabled: versionInfo?.isSourceBuild, + }, + ]; +} + +export function buildIdleDockerUpdateCommand(): string { + return 'docker pull rcourtman/pulse:latest && docker restart pulse'; +} + +export function buildIdleDockerComposeCommand(): string { + return 'docker-compose pull && docker-compose up -d'; +} + +export function buildUpdateInstallGuide( + versionInfo: Pick< + VersionInfo, + 'deploymentType' | 'isDocker' + > | null | undefined, + updateInfo: Pick | null | undefined, + updatePlan: Pick | null | undefined, + dockerImageTag: string, + systemdDownloadCommand: string, +): UpdateInstallGuide | null { + if (!versionInfo) { + return null; + } + + const introText = updatePlan?.canAutoUpdate + ? 'Click "Install Update" above for automatic installation, or update manually:' + : 'Follow these steps to update manually:'; + + if (versionInfo.deploymentType === 'proxmoxve') { + return { + headerTitle: 'Update Available', + headerSummary: `Version ${updateInfo?.latestVersion} is ready to install`, + introText, + steps: [ + { id: 'open-console', title: 'Open your Pulse LXC console' }, + { id: 'run-update', title: 'Run the update command:', command: 'update' }, + { + id: 'run-update-note', + title: '', + note: 'The script will automatically download and install the latest version.', + }, + ], + }; + } + + if (versionInfo.deploymentType === 'docker' || (!versionInfo.deploymentType && versionInfo.isDocker)) { + return { + headerTitle: 'Update Available', + headerSummary: `Version ${updateInfo?.latestVersion} is ready to install`, + introText, + steps: [ + { + id: 'docker-pull', + title: 'Pull the latest image', + command: `docker pull rcourtman/pulse:${dockerImageTag}`, + }, + { + id: 'docker-restart', + title: 'Restart the container', + command: 'docker restart pulse', + }, + { + id: 'docker-compose-note', + title: '', + note: 'Or use Docker Compose: docker-compose pull && docker-compose up -d', + }, + ], + }; + } + + if (versionInfo.deploymentType === 'systemd' || versionInfo.deploymentType === 'manual') { + return { + headerTitle: 'Update Available', + headerSummary: `Version ${updateInfo?.latestVersion} is ready to install`, + introText, + steps: [ + { id: 'systemd-stop', title: 'Stop the service', command: 'sudo systemctl stop pulse' }, + { + id: 'systemd-download', + title: 'Download and extract the new version', + command: systemdDownloadCommand, + commandCodeClass: + 'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all', + }, + { id: 'systemd-start', title: 'Start the service', command: 'sudo systemctl start pulse' }, + ], + }; + } + + if (versionInfo.deploymentType === 'development') { + return { + headerTitle: 'Update Available', + headerSummary: `Version ${updateInfo?.latestVersion} is ready to install`, + introText, + steps: [ + { id: 'development-pull', title: 'Pull the latest changes', command: 'git pull origin main' }, + { id: 'development-build', title: 'Rebuild and restart', command: 'make build && make run' }, + ], + }; + } + + return null; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 8f177fe853..aad5752357 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -19,6 +19,9 @@ import storageSourcesSource from '@/utils/storageSources.ts?raw'; import rbacPermissionsSource from '@/utils/rbacPermissions.ts?raw'; import systemSettingsPresentationSource from '@/utils/systemSettingsPresentation.ts?raw'; import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw'; +import copyCommandBlockSource from '@/components/Settings/CopyCommandBlock.tsx?raw'; +import updateInstallGuideSource from '@/components/Settings/UpdateInstallGuide.tsx?raw'; +import updatesSettingsModelSource from '@/components/Settings/updatesSettingsModel.ts?raw'; import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw'; import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw'; import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw'; @@ -2680,10 +2683,19 @@ describe('frontend resource type boundaries', () => { expect(updatesSettingsPanelSource).toContain('getUpdateAvailabilityHeading'); expect(updatesSettingsPanelSource).toContain('getUpdatePrimaryStatusLabel'); expect(updatesSettingsPanelSource).toContain('getUpdateCheckModeLabel'); + expect(updatesSettingsPanelSource).toContain('@/components/Settings/UpdateInstallGuide'); + expect(updatesSettingsPanelSource).toContain('@/components/Settings/updatesSettingsModel'); expect(updatesSettingsPanelSource).not.toContain('Auto-check enabled'); expect(updatesSettingsPanelSource).not.toContain('Manual checks only'); expect(updatesSettingsPanelSource).not.toContain('Update Ready'); expect(updatesSettingsPanelSource).not.toContain('Up to date'); + expect(updatesSettingsPanelSource).not.toContain("navigator.clipboard.writeText('update')"); + expect(updateInstallGuideSource).toContain('@/components/Settings/CopyCommandBlock'); + expect(updateInstallGuideSource).toContain('buildUpdateInstallGuide'); + expect(copyCommandBlockSource).toContain('export function CopyCommandBlock'); + expect(copyCommandBlockSource).toContain("aria-label=\"Copy to clipboard\""); + expect(updatesSettingsModelSource).toContain('export function getUpdateChannelCardOptions'); + expect(updatesSettingsModelSource).toContain('export function buildUpdateInstallGuide'); expect(updatesPresentationSource).toContain('export function getUpdateBuildBadges'); expect(updatesPresentationSource).toContain('export function getUpdateAvailabilityHeading'); expect(updatesPresentationSource).toContain('export function getUpdatePrimaryStatusLabel');