diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d57ab4fd43..01d7cbb5a4 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +1,9 @@ { - "version": "4.29.2", + "version": "4.32.0", "extraOrigins": [], "sandbox": false, "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" ] -} +} \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 9fe342c7ba..d787ed3fcc 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1027,6 +1027,29 @@ type OnboardingState { activationRequired: Boolean! } +type OnboardingWizardInternalBootState { + applyAttempted: Boolean! + applySucceeded: Boolean! +} + +type OnboardingWizard { + currentStepId: OnboardingWizardStepId + visibleStepIds: [OnboardingWizardStepId!]! + draft: JSON! + internalBootState: OnboardingWizardInternalBootState! +} + +"""Server-provided onboarding wizard step identifiers""" +enum OnboardingWizardStepId { + OVERVIEW + CONFIGURE_SETTINGS + CONFIGURE_BOOT + ADD_PLUGINS + ACTIVATE_LICENSE + SUMMARY + NEXT_STEPS +} + """Onboarding completion state and context""" type Onboarding { """ @@ -1051,6 +1074,9 @@ type Onboarding { """Runtime onboarding state values used by the onboarding flow""" onboardingState: OnboardingState! + + """Server-owned onboarding wizard state used by the web onboarding modal""" + wizard: OnboardingWizard! } """ @@ -1210,6 +1236,16 @@ type ArrayMutations { input ArrayStateInput { """Array state""" desiredState: ArrayStateInputState! + + """ + Optional password used to unlock encrypted array disks when starting the array + """ + decryptionPassword: String + + """ + Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. + """ + decryptionKeyfile: String } enum ArrayStateInputState { @@ -1407,9 +1443,6 @@ type OnboardingMutations { """Force the onboarding modal open""" openOnboarding: Onboarding! - """Close the onboarding modal""" - closeOnboarding: Onboarding! - """Temporarily bypass onboarding in API memory""" bypassOnboarding: Onboarding! @@ -1422,6 +1455,9 @@ type OnboardingMutations { """Clear onboarding override state and reload from disk""" clearOnboardingOverride: Onboarding! + """Persist server-owned onboarding wizard draft state""" + saveOnboardingDraft(input: SaveOnboardingDraftInput!): Boolean! + """Create and configure internal boot pool via emcmd operations""" createInternalBootPool(input: CreateInternalBootPoolInput!): OnboardingInternalBootResult! @@ -1505,6 +1541,21 @@ input PartnerInfoOverrideInput { branding: BrandingConfigInput } +input SaveOnboardingDraftInput { + draft: JSON + navigation: OnboardingWizardNavigationInput + internalBootState: OnboardingWizardInternalBootStateInput +} + +input OnboardingWizardNavigationInput { + currentStepId: OnboardingWizardStepId +} + +input OnboardingWizardInternalBootStateInput { + applyAttempted: Boolean + applySucceeded: Boolean +} + """Input for creating an internal boot pool during onboarding""" input CreateInternalBootPoolInput { poolName: String! @@ -2643,6 +2694,9 @@ type Server implements Node { lanip: String! localurl: String! remoteurl: String! + + """Preferred live URL from nginx.ini defaultUrl""" + defaultUrl: String } enum ServerStatus { diff --git a/api/src/unraid-api/avahi/avahi.service.ts b/api/src/unraid-api/avahi/avahi.service.ts new file mode 100644 index 0000000000..fae5c38f84 --- /dev/null +++ b/api/src/unraid-api/avahi/avahi.service.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { execa } from 'execa'; + +@Injectable() +export class AvahiService { + private readonly logger = new Logger(AvahiService.name); + + async restart(): Promise { + try { + await execa('/etc/rc.d/rc.avahidaemon', ['restart'], { + timeout: 10_000, + }); + this.logger.log('Avahi daemon restarted'); + } catch (error) { + this.logger.error('Failed to restart Avahi daemon', error as Error); + throw error; + } + } +} diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 0b0c497690..bb1f2d282a 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -366,6 +366,10 @@ export enum ArrayState { } export type ArrayStateInput = { + /** Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. */ + decryptionKeyfile?: InputMaybe; + /** Optional password used to unlock encrypted array disks when starting the array */ + decryptionPassword?: InputMaybe; /** Array state */ desiredState: ArrayStateInputState; }; @@ -1976,6 +1980,8 @@ export type Onboarding = { shouldOpen: Scalars['Boolean']['output']; /** The current onboarding status (INCOMPLETE, UPGRADE, DOWNGRADE, or COMPLETED) */ status: OnboardingStatus; + /** Server-owned onboarding wizard state used by the web onboarding modal */ + wizard: OnboardingWizard; }; /** Current onboarding context for configuring internal boot */ @@ -1985,12 +1991,21 @@ export type OnboardingInternalBootContext = { assignableDisks: Array; bootEligible?: Maybe; bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output']; + driveWarnings: Array; enableBootTransfer?: Maybe; poolNames: Array; reservedNames: Array; shareNames: Array; }; +/** Warning metadata for an assignable internal boot drive */ +export type OnboardingInternalBootDriveWarning = { + __typename?: 'OnboardingInternalBootDriveWarning'; + device: Scalars['String']['output']; + diskId: Scalars['String']['output']; + warnings: Array; +}; + /** Result of attempting internal boot pool setup */ export type OnboardingInternalBootResult = { __typename?: 'OnboardingInternalBootResult'; @@ -2006,8 +2021,6 @@ export type OnboardingMutations = { bypassOnboarding: Onboarding; /** Clear onboarding override state and reload from disk */ clearOnboardingOverride: Onboarding; - /** Close the onboarding modal */ - closeOnboarding: Onboarding; /** Mark onboarding as completed */ completeOnboarding: Onboarding; /** Create and configure internal boot pool via emcmd operations */ @@ -2020,6 +2033,8 @@ export type OnboardingMutations = { resetOnboarding: Onboarding; /** Clear the temporary onboarding bypass */ resumeOnboarding: Onboarding; + /** Persist server-owned onboarding wizard draft state */ + saveOnboardingDraft: Scalars['Boolean']['output']; /** Override onboarding state for testing (in-memory only) */ setOnboardingOverride: Onboarding; }; @@ -2031,6 +2046,12 @@ export type OnboardingMutationsCreateInternalBootPoolArgs = { }; +/** Onboarding related mutations */ +export type OnboardingMutationsSaveOnboardingDraftArgs = { + input: SaveOnboardingDraftInput; +}; + + /** Onboarding related mutations */ export type OnboardingMutationsSetOnboardingOverrideArgs = { input: OnboardingOverrideInput; @@ -2072,6 +2093,40 @@ export enum OnboardingStatus { UPGRADE = 'UPGRADE' } +export type OnboardingWizard = { + __typename?: 'OnboardingWizard'; + currentStepId?: Maybe; + draft: Scalars['JSON']['output']; + internalBootState: OnboardingWizardInternalBootState; + visibleStepIds: Array; +}; + +export type OnboardingWizardInternalBootState = { + __typename?: 'OnboardingWizardInternalBootState'; + applyAttempted: Scalars['Boolean']['output']; + applySucceeded: Scalars['Boolean']['output']; +}; + +export type OnboardingWizardInternalBootStateInput = { + applyAttempted?: InputMaybe; + applySucceeded?: InputMaybe; +}; + +export type OnboardingWizardNavigationInput = { + currentStepId?: InputMaybe; +}; + +/** Server-provided onboarding wizard step identifiers */ +export enum OnboardingWizardStepId { + ACTIVATE_LICENSE = 'ACTIVATE_LICENSE', + ADD_PLUGINS = 'ADD_PLUGINS', + CONFIGURE_BOOT = 'CONFIGURE_BOOT', + CONFIGURE_SETTINGS = 'CONFIGURE_SETTINGS', + NEXT_STEPS = 'NEXT_STEPS', + OVERVIEW = 'OVERVIEW', + SUMMARY = 'SUMMARY' +} + export type Owner = { __typename?: 'Owner'; avatar: Scalars['String']['output']; @@ -2594,6 +2649,12 @@ export enum Role { VIEWER = 'VIEWER' } +export type SaveOnboardingDraftInput = { + draft?: InputMaybe; + internalBootState?: InputMaybe; + navigation?: InputMaybe; +}; + export type SensorConfig = { __typename?: 'SensorConfig'; enabled?: Maybe; @@ -2622,6 +2683,8 @@ export type Server = Node & { apikey: Scalars['String']['output']; /** Server description/comment */ comment?: Maybe; + /** Preferred live URL from nginx.ini defaultUrl */ + defaultUrl?: Maybe; guid: Scalars['String']['output']; id: Scalars['PrefixedID']['output']; lanip: Scalars['String']['output']; diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index 5862b7f880..cb7c3e2117 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -169,6 +169,12 @@ describe('OnboardingTracker', () => { completed: false, completedAtVersion: undefined, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }, }); }); @@ -196,6 +202,12 @@ describe('OnboardingTracker', () => { completed: true, completedAtVersion: '7.1.0', forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }, }); }); @@ -288,6 +300,12 @@ describe('OnboardingTracker', () => { completed: true, completedAtVersion: '6.12.0', forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }, }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts index 541f86d321..166b309714 100644 --- a/api/src/unraid-api/config/onboarding-tracker.model.ts +++ b/api/src/unraid-api/config/onboarding-tracker.model.ts @@ -1,6 +1,72 @@ +export const ONBOARDING_STEP_IDS = [ + 'OVERVIEW', + 'CONFIGURE_SETTINGS', + 'CONFIGURE_BOOT', + 'ADD_PLUGINS', + 'ACTIVATE_LICENSE', + 'SUMMARY', + 'NEXT_STEPS', +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +export type OnboardingPoolMode = 'dedicated' | 'hybrid'; + +export type OnboardingBootMode = 'usb' | 'storage'; + +export type OnboardingInternalBootDevice = { + id: string; + sizeBytes: number; + deviceName: string; +}; + +export type OnboardingInternalBootSelection = { + poolName?: string; + slotCount?: number; + devices?: OnboardingInternalBootDevice[]; + bootSizeMiB?: number; + updateBios?: boolean; + poolMode?: OnboardingPoolMode; +}; + +export type OnboardingCoreSettingsDraft = { + serverName?: string; + serverDescription?: string; + timeZone?: string; + theme?: string; + language?: string; + useSsh?: boolean; +}; + +export type OnboardingPluginsDraft = { + selectedIds?: string[]; +}; + +export type OnboardingInternalBootDraft = { + bootMode?: OnboardingBootMode; + skipped?: boolean; + selection?: OnboardingInternalBootSelection | null; +}; + +export type OnboardingDraft = { + activationStepIncluded?: boolean; + coreSettings?: OnboardingCoreSettingsDraft; + plugins?: OnboardingPluginsDraft; + internalBoot?: OnboardingInternalBootDraft; +}; + +export type OnboardingNavigationState = { + currentStepId?: OnboardingStepId; +}; + +export type OnboardingInternalBootState = { + applyAttempted?: boolean; + applySucceeded?: boolean; +}; + /** - * Simplified onboarding tracker state. - * Tracks whether onboarding has been completed and at which version. + * Durable onboarding tracker state. + * Tracks onboarding completion plus the server-owned wizard draft. */ export type TrackerState = { /** Whether the onboarding flow has been completed */ @@ -9,4 +75,10 @@ export type TrackerState = { completedAtVersion?: string; /** Whether onboarding has been explicitly forced open */ forceOpen?: boolean; + /** Durable server-owned wizard draft */ + draft?: OnboardingDraft; + /** Durable navigation state for onboarding resume */ + navigation?: OnboardingNavigationState; + /** Operational internal boot state that is not user-entered draft data */ + internalBootState?: OnboardingInternalBootState; }; diff --git a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts index 26ea4091c8..469a5c6289 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts @@ -18,6 +18,15 @@ vi.mock('atomically', () => ({ const mockReadFile = vi.mocked(readFile); const mockAtomicWriteFile = vi.mocked(atomicWriteFile); +const createEmptyWizardState = () => ({ + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, +}); + const createConfigService = (dataDir = '/tmp/unraid-data') => { const set = vi.fn(); const get = vi.fn((key: string) => { @@ -33,6 +42,12 @@ const createConfigService = (dataDir = '/tmp/unraid-data') => { } as unknown as ConfigService; }; +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + describe('OnboardingTrackerService write retries', () => { beforeEach(() => { mockReadFile.mockReset(); @@ -86,6 +101,52 @@ describe('OnboardingTrackerService write retries', () => { await expect(tracker.markCompleted()).rejects.toThrow('persistent-write-failure'); expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3); }); + + it('clears wizard state while preserving completion metadata', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: true, + draft: { + coreSettings: { + serverName: 'Tower', + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.clearWizardState()).resolves.toEqual({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: true, + ...createEmptyWizardState(), + }); + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: true, + ...createEmptyWizardState(), + }); + }); }); describe('OnboardingTrackerService tracker state availability', () => { @@ -114,6 +175,7 @@ describe('OnboardingTrackerService tracker state availability', () => { completed: false, completedAtVersion: undefined, forceOpen: false, + ...createEmptyWizardState(), }, }); }); @@ -166,6 +228,44 @@ describe('OnboardingTrackerService tracker state availability', () => { completed: true, completedAtVersion: '7.2.0', forceOpen: false, + ...createEmptyWizardState(), + }, + }); + }); + + it('preserves persisted completion fields when a partial override only forces onboarding open', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + overrides.setState({ + onboarding: { + forceOpen: true, + }, + }); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: true, + completedAtVersion: '7.2.0', + forceOpen: false, + ...createEmptyWizardState(), + }); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.getStateResult()).resolves.toEqual({ + kind: 'ok', + state: { + completed: true, + completedAtVersion: '7.2.0', + forceOpen: true, + ...createEmptyWizardState(), }, }); }); @@ -196,6 +296,7 @@ describe('OnboardingTrackerService tracker state availability', () => { completed: true, completedAtVersion: '7.2.0', forceOpen: false, + ...createEmptyWizardState(), }); }); @@ -225,6 +326,7 @@ describe('OnboardingTrackerService tracker state availability', () => { completed: false, completedAtVersion: undefined, forceOpen: false, + ...createEmptyWizardState(), }); }); @@ -261,4 +363,504 @@ describe('OnboardingTrackerService tracker state availability', () => { await expect(tracker.getCompletedAtVersion()).rejects.toThrow('permission denied'); }); + + it('merges partial draft updates without wiping sibling step data', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: false, + completedAtVersion: undefined, + forceOpen: true, + draft: { + coreSettings: { + serverName: 'Tower', + timeZone: 'America/New_York', + }, + activationStepIncluded: true, + plugins: { + selectedIds: ['community.applications'], + }, + }, + navigation: { + currentStepId: 'ADD_PLUGINS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect( + tracker.saveDraft({ + draft: { + internalBoot: { + bootMode: 'storage', + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('disk1', 500_000_000_000, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'dedicated', + }, + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + }) + ).resolves.toEqual({ + completed: false, + completedAtVersion: undefined, + forceOpen: true, + draft: { + activationStepIncluded: true, + coreSettings: { + serverName: 'Tower', + timeZone: 'America/New_York', + }, + plugins: { + selectedIds: ['community.applications'], + }, + internalBoot: { + bootMode: 'storage', + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('disk1', 500_000_000_000, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'dedicated', + }, + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1); + const writtenState = JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1])) as { + draft?: { + coreSettings?: { serverName?: string }; + internalBoot?: { selection?: { poolName?: string } }; + }; + navigation?: { currentStepId?: string }; + }; + expect(writtenState.draft?.coreSettings?.serverName).toBe('Tower'); + expect(writtenState.navigation?.currentStepId).toBe('SUMMARY'); + expect(writtenState.draft?.internalBoot?.selection?.poolName).toBe('cache'); + }); + + it('saves drafts against persisted tracker state when overrides are active', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + overrides.setState({ + onboarding: { + completed: false, + completedAtVersion: undefined, + forceOpen: true, + }, + }); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: false, + draft: { + coreSettings: { + serverName: 'Tower', + }, + }, + navigation: { + currentStepId: 'CONFIGURE_SETTINGS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect( + tracker.saveDraft({ + draft: { + plugins: { + selectedIds: ['community.applications'], + }, + }, + }) + ).resolves.toEqual({ + completed: false, + completedAtVersion: '7.1.0', + forceOpen: true, + draft: { + coreSettings: { + serverName: 'Tower', + serverDescription: undefined, + timeZone: undefined, + theme: undefined, + language: undefined, + useSsh: undefined, + }, + plugins: { + selectedIds: ['community.applications'], + }, + internalBoot: undefined, + }, + navigation: { + currentStepId: 'CONFIGURE_SETTINGS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: false, + draft: { + coreSettings: { + serverName: 'Tower', + }, + plugins: { + selectedIds: ['community.applications'], + }, + }, + navigation: { + currentStepId: 'CONFIGURE_SETTINGS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + + it('clears wizard state on disk while leaving override-backed onboarding state temporary', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + overrides.setState({ + onboarding: { + completed: false, + completedAtVersion: undefined, + forceOpen: true, + }, + }); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: false, + draft: { + coreSettings: { + serverName: 'Tower', + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.clearWizardState()).resolves.toEqual({ + completed: false, + completedAtVersion: '7.1.0', + forceOpen: true, + draft: { + coreSettings: undefined, + plugins: undefined, + internalBoot: undefined, + }, + navigation: { + currentStepId: undefined, + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({ + completed: true, + completedAtVersion: '7.1.0', + forceOpen: false, + ...createEmptyWizardState(), + }); + }); + + it('persists internal boot status updates while preserving existing draft state', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: { + activationStepIncluded: true, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + ], + bootSizeMiB: 32768, + updateBios: false, + poolMode: 'hybrid', + }, + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect( + tracker.saveDraft({ + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }) + ).resolves.toEqual({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: { + activationStepIncluded: true, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + ], + bootSizeMiB: 32768, + updateBios: false, + poolMode: 'hybrid', + }, + }, + }, + navigation: { + currentStepId: 'SUMMARY', + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }); + }); + + it('drops unknown JSON keys while preserving valid onboarding draft fields', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect( + tracker.saveDraft({ + draft: { + coreSettings: { + serverName: 'Tower', + theme: 'black', + }, + internalBoot: { + bootMode: 'storage', + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('disk1', 500_000_000_000, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + ignoredSelectionField: 'ignore-me', + } as unknown as Record, + ignoredDraftField: 'ignore-me', + } as unknown as Record, + ignoredTopLevelField: { + nested: true, + }, + } as unknown as Record, + }) + ).resolves.toMatchObject({ + draft: { + coreSettings: { + serverName: 'Tower', + theme: 'black', + }, + internalBoot: { + bootMode: 'storage', + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('disk1', 500_000_000_000, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }, + }); + + const writtenState = JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1])) as { + draft?: Record; + }; + expect(writtenState.draft?.ignoredTopLevelField).toBeUndefined(); + expect( + (writtenState.draft?.internalBoot as Record | undefined)?.ignoredDraftField + ).toBeUndefined(); + expect( + ( + (writtenState.draft?.internalBoot as Record | undefined)?.selection as + | Record + | undefined + )?.ignoredSelectionField + ).toBeUndefined(); + }); + + it('truncates persisted internal-boot devices to the clamped slot count', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + + return JSON.stringify({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); + }); + mockAtomicWriteFile.mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect( + tracker.saveDraft({ + draft: { + internalBoot: { + bootMode: 'storage', + selection: { + poolName: 'cache', + slotCount: 99, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + createBootDevice('disk3', 125_000_000_000, 'sdc'), + ], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }, + }) + ).resolves.toMatchObject({ + draft: { + internalBoot: { + selection: { + slotCount: 2, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + ], + }, + }, + }, + }); + }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index 4d005128f0..c7692ed44a 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -5,9 +5,23 @@ import path from 'path'; import { writeFile } from 'atomically'; -import type { TrackerState } from '@app/unraid-api/config/onboarding-tracker.model.js'; +import type { + OnboardingBootMode, + OnboardingCoreSettingsDraft, + OnboardingDraft, + OnboardingInternalBootDevice, + OnboardingInternalBootDraft, + OnboardingInternalBootSelection, + OnboardingInternalBootState, + OnboardingNavigationState, + OnboardingPluginsDraft, + OnboardingPoolMode, + OnboardingStepId, + TrackerState, +} from '@app/unraid-api/config/onboarding-tracker.model.js'; import { PATHS_CONFIG_MODULES } from '@app/environment.js'; import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { ONBOARDING_STEP_IDS } from '@app/unraid-api/config/onboarding-tracker.model.js'; const TRACKER_FILE_NAME = 'onboarding-tracker.json'; const CONFIG_PREFIX = 'onboardingTracker'; @@ -15,13 +29,249 @@ const DEFAULT_OS_VERSION_FILE_PATH = '/etc/unraid-version'; const WRITE_RETRY_ATTEMPTS = 3; const WRITE_RETRY_DELAY_MS = 100; -type PublicTrackerState = { completed: boolean; completedAtVersion?: string; forceOpen: boolean }; +export type PublicTrackerState = { + completed: boolean; + completedAtVersion?: string; + forceOpen: boolean; + draft: OnboardingDraft; + navigation: OnboardingNavigationState; + internalBootState: OnboardingInternalBootState; +}; export type OnboardingTrackerStateResult = | { kind: 'ok'; state: PublicTrackerState } | { kind: 'missing'; state: PublicTrackerState } | { kind: 'error'; error: Error }; +type SaveOnboardingDraftInput = { + draft?: OnboardingDraft; + navigation?: OnboardingNavigationState; + internalBootState?: OnboardingInternalBootState; +}; + +const normalizeBoolean = (value: unknown, fallback = false): boolean => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + if (normalized === 'false') { + return false; + } + } + + return fallback; +}; + +const normalizeString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : ''; +}; + +const normalizeStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is string => typeof item === 'string'); +}; + +const normalizeBootDevice = (value: unknown): OnboardingInternalBootDevice | null => { + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Record; + const id = normalizeString(candidate.id); + const deviceName = normalizeString(candidate.deviceName); + const parsedSizeBytes = Number(candidate.sizeBytes); + + if (!id || !deviceName || !Number.isFinite(parsedSizeBytes) || parsedSizeBytes <= 0) { + return null; + } + + return { + id, + sizeBytes: parsedSizeBytes, + deviceName, + }; +}; + +const normalizeBootDeviceArray = (value: unknown): OnboardingInternalBootDevice[] => { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeBootDevice(item)) + .filter((item): item is OnboardingInternalBootDevice => item !== null); +}; + +const normalizeStepId = (value: unknown): OnboardingStepId | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + return ONBOARDING_STEP_IDS.includes(value as OnboardingStepId) + ? (value as OnboardingStepId) + : undefined; +}; + +const normalizePoolMode = (value: unknown): OnboardingPoolMode | undefined => { + if (value === 'dedicated' || value === 'hybrid') { + return value; + } + + return undefined; +}; + +const normalizeBootMode = (value: unknown): OnboardingBootMode | undefined => { + if (value === 'usb' || value === 'storage') { + return value; + } + + return undefined; +}; + +const normalizeInternalBootSelection = (value: unknown): OnboardingInternalBootSelection | null => { + if (value === null) { + return null; + } + + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Record; + const parsedSlotCount = Number(candidate.slotCount); + const parsedBootSize = Number(candidate.bootSizeMiB); + const slotCount = Number.isFinite(parsedSlotCount) + ? Math.max(1, Math.min(2, parsedSlotCount)) + : undefined; + + return { + poolName: normalizeString(candidate.poolName), + slotCount, + devices: normalizeBootDeviceArray(candidate.devices).slice(0, slotCount ?? 2), + bootSizeMiB: Number.isFinite(parsedBootSize) ? Math.max(0, parsedBootSize) : undefined, + updateBios: normalizeBoolean(candidate.updateBios, false), + poolMode: normalizePoolMode(candidate.poolMode), + }; +}; + +const normalizeCoreSettingsDraft = (value: unknown): OnboardingCoreSettingsDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + serverName: normalizeString(candidate.serverName), + serverDescription: normalizeString(candidate.serverDescription), + timeZone: normalizeString(candidate.timeZone), + theme: normalizeString(candidate.theme), + language: normalizeString(candidate.language), + useSsh: typeof candidate.useSsh === 'boolean' ? candidate.useSsh : undefined, + }; +}; + +const normalizePluginsDraft = (value: unknown): OnboardingPluginsDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + selectedIds: normalizeStringArray(candidate.selectedIds), + }; +}; + +const normalizeInternalBootDraft = (value: unknown): OnboardingInternalBootDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + bootMode: normalizeBootMode(candidate.bootMode), + skipped: typeof candidate.skipped === 'boolean' ? candidate.skipped : undefined, + selection: + candidate.selection === undefined + ? undefined + : normalizeInternalBootSelection(candidate.selection), + }; +}; + +const normalizeDraft = (value: unknown): OnboardingDraft => { + if (!value || typeof value !== 'object') { + return {}; + } + + const candidate = value as Record; + const activationStepIncluded = + typeof candidate.activationStepIncluded === 'boolean' + ? candidate.activationStepIncluded + : undefined; + + return { + ...(activationStepIncluded === undefined ? {} : { activationStepIncluded }), + coreSettings: normalizeCoreSettingsDraft(candidate.coreSettings), + plugins: normalizePluginsDraft(candidate.plugins), + internalBoot: normalizeInternalBootDraft(candidate.internalBoot), + }; +}; + +const normalizeNavigation = (value: unknown): OnboardingNavigationState => { + if (!value || typeof value !== 'object') { + return {}; + } + + const candidate = value as Record; + + return { + currentStepId: normalizeStepId(candidate.currentStepId), + }; +}; + +const normalizeInternalBootState = (value: unknown): OnboardingInternalBootState => { + if (!value || typeof value !== 'object') { + return { + applyAttempted: false, + applySucceeded: false, + }; + } + + const candidate = value as Record; + + return { + applyAttempted: normalizeBoolean(candidate.applyAttempted, false), + applySucceeded: normalizeBoolean(candidate.applySucceeded, false), + }; +}; + +const createEmptyPublicState = (): PublicTrackerState => ({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, +}); + /** * Simplified onboarding tracker service. * Tracks whether onboarding has been completed and at which version. @@ -53,21 +303,28 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { } private getCachedState(): PublicTrackerState { - // Check for override first (for testing) + const baseState = { + ...createEmptyPublicState(), + completed: this.state.completed ?? false, + completedAtVersion: this.state.completedAtVersion, + forceOpen: this.state.forceOpen ?? false, + draft: normalizeDraft(this.state.draft), + navigation: normalizeNavigation(this.state.navigation), + internalBootState: normalizeInternalBootState(this.state.internalBootState), + }; + const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { return { - completed: overrideState.onboarding.completed ?? false, - completedAtVersion: overrideState.onboarding.completedAtVersion ?? undefined, - forceOpen: overrideState.onboarding.forceOpen ?? false, + ...baseState, + completed: overrideState.onboarding.completed ?? baseState.completed, + completedAtVersion: + overrideState.onboarding.completedAtVersion ?? baseState.completedAtVersion, + forceOpen: overrideState.onboarding.forceOpen ?? baseState.forceOpen, }; } - return { - completed: this.state.completed ?? false, - completedAtVersion: this.state.completedAtVersion, - forceOpen: this.state.forceOpen ?? false, - }; + return baseState; } /** @@ -114,12 +371,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { }; } - const trackerStateResult = await this.readTrackerStateResult(); - if (trackerStateResult.kind !== 'error') { - this.state = trackerStateResult.state; - } - - return trackerStateResult; + return this.getPersistedStateResult(); } /** @@ -127,7 +379,6 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { */ async markCompleted(): Promise<{ completed: boolean; completedAtVersion?: string }> { this.bypassActive = false; - // Check for override first const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { const updatedOverride = { @@ -148,6 +399,12 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: true, completedAtVersion: this.currentVersion, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; await this.writeTrackerState(updatedState); @@ -161,7 +418,6 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { */ async reset(): Promise<{ completed: boolean; completedAtVersion?: string }> { this.bypassActive = false; - // Check for override first const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { const updatedOverride = { @@ -181,6 +437,12 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: false, completedAtVersion: undefined, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; await this.writeTrackerState(updatedState); @@ -221,6 +483,9 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: currentStateResult.state.completed, completedAtVersion: currentStateResult.state.completedAtVersion, forceOpen, + draft: currentStateResult.state.draft, + navigation: currentStateResult.state.navigation, + internalBootState: currentStateResult.state.internalBootState, }; await this.writeTrackerState(updatedState); @@ -233,6 +498,81 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { this.bypassActive = active; } + async clearWizardState(): Promise { + const currentStateResult = await this.getPersistedStateResult(); + if (currentStateResult.kind === 'error') { + throw currentStateResult.error; + } + + const currentState = currentStateResult.state; + const updatedState: TrackerState = { + completed: currentState.completed, + completedAtVersion: currentState.completedAtVersion, + forceOpen: currentState.forceOpen, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(); + + return this.getCachedState(); + } + + async saveDraft(input: SaveOnboardingDraftInput): Promise { + const currentStateResult = await this.getPersistedStateResult(); + if (currentStateResult.kind === 'error') { + throw currentStateResult.error; + } + + const currentState = currentStateResult.state; + const nextDraft = normalizeDraft({ + ...currentState.draft, + ...(input.draft ?? {}), + }); + + const updatedState: TrackerState = { + completed: currentState.completed, + completedAtVersion: currentState.completedAtVersion, + forceOpen: currentState.forceOpen, + draft: nextDraft, + navigation: normalizeNavigation( + input.navigation + ? { + ...currentState.navigation, + ...input.navigation, + } + : currentState.navigation + ), + internalBootState: normalizeInternalBootState( + input.internalBootState + ? { + ...currentState.internalBootState, + ...input.internalBootState, + } + : currentState.internalBootState + ), + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(); + + return this.getCachedState(); + } + + private async getPersistedStateResult(): Promise { + const trackerStateResult = await this.readTrackerStateResult(); + if (trackerStateResult.kind !== 'error') { + this.state = trackerStateResult.state; + } + + return trackerStateResult; + } + private async readCurrentVersion(): Promise { try { const contents = await readFile(this.versionFilePath, 'utf8'); @@ -256,6 +596,9 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: state.completed ?? false, completedAtVersion: state.completedAtVersion, forceOpen: state.forceOpen ?? false, + draft: normalizeDraft(state.draft), + navigation: normalizeNavigation(state.navigation), + internalBootState: normalizeInternalBootState(state.internalBootState), }, }; } catch (error) { @@ -263,11 +606,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { this.logger.debug(`Onboarding tracker state does not exist yet at ${this.trackerPath}.`); return { kind: 'missing', - state: { - completed: false, - completedAtVersion: undefined, - forceOpen: false, - }, + state: createEmptyPublicState(), }; } diff --git a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts index 3e1747a4ed..e766e2d5e8 100644 --- a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts +++ b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts @@ -2,7 +2,9 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; +import { GraphQLJSON } from 'graphql-scalars'; +import type { OnboardingDraft } from '@app/unraid-api/config/onboarding-tracker.model.js'; import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; @@ -371,6 +373,65 @@ registerEnumType(OnboardingStatus, { description: 'The current onboarding status based on completion state and version relationship', }); +export enum OnboardingWizardStepId { + OVERVIEW = 'OVERVIEW', + CONFIGURE_SETTINGS = 'CONFIGURE_SETTINGS', + CONFIGURE_BOOT = 'CONFIGURE_BOOT', + ADD_PLUGINS = 'ADD_PLUGINS', + ACTIVATE_LICENSE = 'ACTIVATE_LICENSE', + SUMMARY = 'SUMMARY', + NEXT_STEPS = 'NEXT_STEPS', +} + +registerEnumType(OnboardingWizardStepId, { + name: 'OnboardingWizardStepId', + description: 'Server-provided onboarding wizard step identifiers', +}); + +export enum OnboardingWizardBootMode { + USB = 'usb', + STORAGE = 'storage', +} + +registerEnumType(OnboardingWizardBootMode, { + name: 'OnboardingWizardBootMode', + description: 'Boot mode selected during onboarding', +}); + +export enum OnboardingWizardPoolMode { + DEDICATED = 'dedicated', + HYBRID = 'hybrid', +} + +registerEnumType(OnboardingWizardPoolMode, { + name: 'OnboardingWizardPoolMode', + description: 'Pool mode selected for onboarding internal boot setup', +}); + +@ObjectType() +export class OnboardingWizardInternalBootState { + @Field(() => Boolean) + applyAttempted!: boolean; + + @Field(() => Boolean) + applySucceeded!: boolean; +} + +@ObjectType() +export class OnboardingWizard { + @Field(() => OnboardingWizardStepId, { nullable: true }) + currentStepId?: OnboardingWizardStepId; + + @Field(() => [OnboardingWizardStepId]) + visibleStepIds!: OnboardingWizardStepId[]; + + @Field(() => GraphQLJSON) + draft!: OnboardingDraft; + + @Field(() => OnboardingWizardInternalBootState) + internalBootState!: OnboardingWizardInternalBootState; +} + @ObjectType({ description: 'Onboarding completion state and context', }) @@ -411,6 +472,11 @@ export class Onboarding { description: 'Runtime onboarding state values used by the onboarding flow', }) onboardingState!: OnboardingState; + + @Field(() => OnboardingWizard, { + description: 'Server-owned onboarding wizard state used by the web onboarding modal', + }) + wizard!: OnboardingWizard; } @ObjectType() diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts index 6e1d2a8dec..ea38accfeb 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts @@ -1,11 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { + OnboardingStatus, + OnboardingWizardStepId, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; describe('CustomizationResolver', () => { + const emptyWizardState = { + currentStepId: OnboardingWizardStepId.OVERVIEW, + visibleStepIds: [ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + OnboardingWizardStepId.ADD_PLUGINS, + OnboardingWizardStepId.SUMMARY, + OnboardingWizardStepId.NEXT_STEPS, + ], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const onboardingService = { getActivationData: vi.fn(), getActivationDataForPublic: vi.fn(), @@ -38,6 +56,7 @@ describe('CustomizationResolver', () => { completed: false, completedAtVersion: undefined, shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -63,6 +82,7 @@ describe('CustomizationResolver', () => { completed: false, completedAtVersion: undefined, shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -80,6 +100,7 @@ describe('CustomizationResolver', () => { completed: false, completedAtVersion: undefined, shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -97,6 +118,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.2.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -114,6 +136,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.2.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -131,6 +154,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.2.1', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -148,6 +172,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.2.1', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -165,6 +190,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.1.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -182,6 +208,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.1.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -199,6 +226,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.3.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -216,6 +244,7 @@ describe('CustomizationResolver', () => { completed: true, completedAtVersion: '7.3.0', shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, @@ -233,6 +262,7 @@ describe('CustomizationResolver', () => { completed: false, completedAtVersion: undefined, shouldOpen: false, + wizard: emptyWizardState, onboardingState: { registrationState: undefined, isRegistered: false, diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index 56f98d4b5d..4009f37217 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -17,9 +17,16 @@ import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-trac import { ActivationCode, OnboardingStatus, + OnboardingWizardStepId, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + vi.mock('@app/core/utils/files/file-exists.js'); vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); @@ -134,6 +141,8 @@ const onboardingTrackerMock = { ) => Promise<{ completed: boolean; completedAtVersion?: string; forceOpen: boolean }> >(), setBypassActive: vi.fn<(active: boolean) => void>(), + clearWizardState: vi.fn(), + saveDraft: vi.fn(), }; const onboardingOverridesMock = { getState: vi.fn(), @@ -206,6 +215,12 @@ describe('OnboardingService', () => { completed: false, completedAtVersion: undefined, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }, }); onboardingTrackerMock.getCurrentVersion.mockReset(); @@ -231,6 +246,18 @@ describe('OnboardingService', () => { forceOpen, })); onboardingTrackerMock.setBypassActive.mockReset(); + onboardingTrackerMock.saveDraft.mockReset(); + onboardingTrackerMock.saveDraft.mockResolvedValue({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }); onboardingOverridesMock.getState.mockReset(); onboardingOverridesMock.getState.mockReturnValue(null); onboardingOverridesMock.setState.mockReset(); @@ -320,7 +347,7 @@ describe('OnboardingService', () => { await expect( service.getOnboardingResponse({ includeActivationCode: true }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: OnboardingStatus.COMPLETED, isPartnerBuild: true, completed: true, @@ -334,6 +361,9 @@ describe('OnboardingService', () => { hasActivationCode: true, activationRequired: false, }, + wizard: { + currentStepId: OnboardingWizardStepId.OVERVIEW, + }, }); }); @@ -544,84 +574,17 @@ describe('OnboardingService', () => { }); describe('visibility actions', () => { - it('delegates openOnboarding to the tracker', async () => { - await service.openOnboarding(); - - expect(onboardingTrackerMock.setBypassActive).toHaveBeenCalledWith(false); - expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(true); - }); - - it('clears forced-open onboarding through the tracker', async () => { - onboardingTrackerMock.getStateResult.mockResolvedValue({ - kind: 'ok', - state: { - completed: true, - completedAtVersion: '7.3.0', - forceOpen: true, - }, - }); - - await service.closeOnboarding(); - - expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(false); - }); - - it('marks incomplete onboarding complete when closed on supported versions', async () => { - onboardingTrackerMock.getStateResult.mockResolvedValue({ - kind: 'ok', - state: { - completed: false, - completedAtVersion: undefined, - forceOpen: false, - }, - }); - onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.3.0'); - onboardingStateMock.isFreshInstall.mockReturnValue(true); - - await service.closeOnboarding(); + it('marks onboarding complete through the tracker', async () => { + await service.markOnboardingCompleted(); expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1); - expect(onboardingTrackerMock.setForceOpen).not.toHaveBeenCalled(); }); - it('marks licensed incomplete onboarding complete when closed on supported versions', async () => { - onboardingTrackerMock.getStateResult.mockResolvedValue({ - kind: 'ok', - state: { - completed: false, - completedAtVersion: undefined, - forceOpen: false, - }, - }); - onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.3.0'); - onboardingStateMock.getRegistrationState.mockReturnValue('PRO'); - onboardingStateMock.isRegistered.mockReturnValue(true); - onboardingStateMock.hasActivationCode.mockResolvedValue(false); - onboardingStateMock.requiresActivationStep.mockReturnValue(false); - onboardingStateMock.isFreshInstall.mockReturnValue(false); - - await service.closeOnboarding(); - - expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1); - expect(onboardingTrackerMock.setForceOpen).not.toHaveBeenCalled(); - }); - - it('closes force-opened fresh incomplete onboarding in one action', async () => { - onboardingTrackerMock.getStateResult.mockResolvedValue({ - kind: 'ok', - state: { - completed: false, - completedAtVersion: undefined, - forceOpen: true, - }, - }); - onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.3.0'); - onboardingStateMock.isFreshInstall.mockReturnValue(true); - - await service.closeOnboarding(); + it('delegates openOnboarding to the tracker', async () => { + await service.openOnboarding(); - expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(false); - expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1); + expect(onboardingTrackerMock.setBypassActive).toHaveBeenCalledWith(false); + expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(true); }); it('enables the in-memory bypass', async () => { @@ -1845,4 +1808,245 @@ describe('OnboardingService - updateCfgFile', () => { expect((await service.getTheme()).showBannerGradient).toBe(false); }); }); + + describe('wizard state', () => { + const mockEmhttp = getters.emhttp as unknown as Mock; + + it('returns live-computed visible steps and falls forward when the saved current step is hidden', async () => { + mockEmhttp.mockReturnValue({ + var: { + name: 'Tower', + sysModel: 'Custom', + comment: 'Default', + enableBootTransfer: 'no', + }, + }); + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'ok', + state: { + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: { + plugins: { + selectedIds: ['community.applications'], + }, + }, + navigation: { + currentStepId: 'CONFIGURE_BOOT', + }, + internalBootState: { + applyAttempted: true, + applySucceeded: false, + }, + }, + }); + onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.3.0'); + onboardingStateMock.getRegistrationState.mockReturnValue('PRO'); + onboardingStateMock.isRegistered.mockReturnValue(true); + onboardingStateMock.isFreshInstall.mockReturnValue(true); + onboardingStateMock.hasActivationCode.mockResolvedValue(false); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null); + + const response = await service.getOnboardingResponse(); + + expect(response.status).toBe(OnboardingStatus.INCOMPLETE); + expect(response.wizard.visibleStepIds).toEqual([ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + OnboardingWizardStepId.ADD_PLUGINS, + OnboardingWizardStepId.SUMMARY, + OnboardingWizardStepId.NEXT_STEPS, + ]); + expect(response.wizard.currentStepId).toBe(OnboardingWizardStepId.ADD_PLUGINS); + expect(response.wizard.draft).toEqual({ + plugins: { + selectedIds: ['community.applications'], + }, + }); + expect(response.wizard.internalBootState).toEqual({ + applyAttempted: true, + applySucceeded: false, + }); + }); + + it('marks the activation step sticky when activation is required', async () => { + mockEmhttp.mockReturnValue({ + var: { + name: 'Tower', + sysModel: 'Custom', + comment: 'Default', + enableBootTransfer: 'no', + }, + }); + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'ok', + state: { + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: { + plugins: { + selectedIds: ['community.applications'], + }, + }, + navigation: { + currentStepId: 'ADD_PLUGINS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, + }); + onboardingStateMock.getRegistrationState.mockReturnValue('ENOKEYFILE'); + onboardingStateMock.hasActivationCode.mockResolvedValue(true); + onboardingStateMock.requiresActivationStep.mockReturnValue(true); + onboardingStateMock.isRegistered.mockReturnValue(false); + onboardingStateMock.isFreshInstall.mockReturnValue(true); + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null); + + const response = await service.getOnboardingResponse(); + + expect(onboardingTrackerMock.saveDraft).toHaveBeenCalledWith({ + draft: { + activationStepIncluded: true, + }, + }); + expect(response.wizard.visibleStepIds).toContain(OnboardingWizardStepId.ACTIVATE_LICENSE); + expect(response.wizard.draft).toEqual({ + activationStepIncluded: true, + plugins: { + selectedIds: ['community.applications'], + }, + }); + }); + + it('keeps the activation step visible when the sticky draft flag is set', async () => { + mockEmhttp.mockReturnValue({ + var: { + name: 'Tower', + sysModel: 'Custom', + comment: 'Default', + enableBootTransfer: 'no', + }, + }); + onboardingTrackerMock.getStateResult.mockResolvedValue({ + kind: 'ok', + state: { + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: { + activationStepIncluded: true, + }, + navigation: { + currentStepId: 'ACTIVATE_LICENSE', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, + }); + onboardingStateMock.getRegistrationState.mockReturnValue('PRO'); + onboardingStateMock.hasActivationCode.mockResolvedValue(false); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + onboardingStateMock.isRegistered.mockReturnValue(true); + onboardingStateMock.isFreshInstall.mockReturnValue(true); + vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null); + + const response = await service.getOnboardingResponse(); + + expect(onboardingTrackerMock.saveDraft).not.toHaveBeenCalled(); + expect(response.wizard.visibleStepIds).toContain(OnboardingWizardStepId.ACTIVATE_LICENSE); + expect(response.wizard.currentStepId).toBe(OnboardingWizardStepId.ACTIVATE_LICENSE); + }); + + it('persists JSON-backed wizard draft input in tracker format', async () => { + await service.saveOnboardingDraft({ + draft: { + coreSettings: { + serverName: 'Tower', + timeZone: 'America/New_York', + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + ], + bootSizeMiB: 32768, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }, + navigation: { + currentStepId: OnboardingWizardStepId.SUMMARY, + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }); + + expect(onboardingTrackerMock.saveDraft).toHaveBeenCalledWith({ + draft: { + coreSettings: { + serverName: 'Tower', + timeZone: 'America/New_York', + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [ + createBootDevice('disk1', 500_000_000_000, 'sda'), + createBootDevice('disk2', 250_000_000_000, 'sdb'), + ], + bootSizeMiB: 32768, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }, + navigation: { + currentStepId: OnboardingWizardStepId.SUMMARY, + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }); + }); + + it('skips saving the onboarding draft when the expected server name does not match the live identity', async () => { + vi.spyOn(getters, 'emhttp').mockReturnValue({ + ...getters.emhttp(), + nginx: { + ...getters.emhttp().nginx, + lanName: 'Tower', + }, + }); + + await expect( + service.saveOnboardingDraft({ + navigation: { + currentStepId: OnboardingWizardStepId.NEXT_STEPS, + }, + expectedServerName: 'Cheese', + }) + ).resolves.toBe(false); + + expect(onboardingTrackerMock.saveDraft).not.toHaveBeenCalled(); + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index 1d08bea764..9e69082243 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -9,6 +9,10 @@ import * as ini from 'ini'; import coerce from 'semver/functions/coerce.js'; import gte from 'semver/functions/gte.js'; +import type { + OnboardingDraft, + OnboardingStepId, +} from '@app/unraid-api/config/onboarding-tracker.model.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js'; import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; @@ -23,6 +27,8 @@ import { Onboarding, OnboardingState, OnboardingStatus, + OnboardingWizard, + OnboardingWizardStepId, PublicPartnerInfo, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { @@ -31,8 +37,18 @@ import { } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; +import { SaveOnboardingDraftInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; const MIN_ONBOARDING_VERSION = '7.3.0'; +const WIZARD_STEP_ORDER: OnboardingWizardStepId[] = [ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + OnboardingWizardStepId.CONFIGURE_BOOT, + OnboardingWizardStepId.ADD_PLUGINS, + OnboardingWizardStepId.ACTIVATE_LICENSE, + OnboardingWizardStepId.SUMMARY, + OnboardingWizardStepId.NEXT_STEPS, +]; @Injectable() export class OnboardingService implements OnModuleInit { @@ -344,6 +360,117 @@ export class OnboardingService implements OnModuleInit { }; } + private shouldShowInternalBootStep(): boolean { + const enableBootTransfer = getters.emhttp().var?.enableBootTransfer; + return ( + typeof enableBootTransfer === 'string' && enableBootTransfer.trim().toLowerCase() === 'yes' + ); + } + + private getVisibleWizardStepIds( + onboardingState: OnboardingState, + draft: OnboardingDraft + ): OnboardingWizardStepId[] { + const visibleStepIds: OnboardingWizardStepId[] = [ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + ]; + + if (this.shouldShowInternalBootStep()) { + visibleStepIds.push(OnboardingWizardStepId.CONFIGURE_BOOT); + } + + visibleStepIds.push(OnboardingWizardStepId.ADD_PLUGINS); + + if (onboardingState.activationRequired || draft.activationStepIncluded) { + visibleStepIds.push(OnboardingWizardStepId.ACTIVATE_LICENSE); + } + + visibleStepIds.push(OnboardingWizardStepId.SUMMARY, OnboardingWizardStepId.NEXT_STEPS); + + return visibleStepIds; + } + + private resolveCurrentStepId( + currentStepId: OnboardingStepId | undefined, + visibleStepIds: OnboardingWizardStepId[] + ): OnboardingWizardStepId | undefined { + if (currentStepId && visibleStepIds.includes(currentStepId as OnboardingWizardStepId)) { + return currentStepId as OnboardingWizardStepId; + } + + if (!currentStepId) { + return visibleStepIds[0]; + } + + const currentOrderIndex = WIZARD_STEP_ORDER.indexOf(currentStepId as OnboardingWizardStepId); + if (currentOrderIndex < 0) { + return visibleStepIds[0]; + } + + for (let index = currentOrderIndex + 1; index < WIZARD_STEP_ORDER.length; index += 1) { + const nextStepId = WIZARD_STEP_ORDER[index]; + if (nextStepId && visibleStepIds.includes(nextStepId)) { + return nextStepId; + } + } + + for (let index = currentOrderIndex - 1; index >= 0; index -= 1) { + const previousStepId = WIZARD_STEP_ORDER[index]; + if (previousStepId && visibleStepIds.includes(previousStepId)) { + return previousStepId; + } + } + + return visibleStepIds[0]; + } + + private buildWizardState( + state: { + navigation?: { currentStepId?: OnboardingStepId }; + internalBootState?: { applyAttempted?: boolean; applySucceeded?: boolean }; + }, + onboardingState: OnboardingState, + draft: OnboardingDraft + ): OnboardingWizard { + const visibleStepIds = this.getVisibleWizardStepIds(onboardingState, draft); + const navigation = state.navigation ?? {}; + const internalBootState = state.internalBootState ?? {}; + + return { + currentStepId: this.resolveCurrentStepId(navigation.currentStepId, visibleStepIds), + visibleStepIds, + draft, + internalBootState: { + applyAttempted: internalBootState.applyAttempted ?? false, + applySucceeded: internalBootState.applySucceeded ?? false, + }, + }; + } + + // Activation can complete outside onboarding via the Account app callback. Once activation + // was part of this onboarding session, keep the step visible until the draft is cleared. + private async getWizardDraft( + state: { completed?: boolean; draft?: OnboardingDraft }, + onboardingState: OnboardingState + ): Promise { + const draft = state.draft ?? {}; + if (state.completed || !onboardingState.activationRequired || draft.activationStepIncluded) { + return draft; + } + + await this.onboardingTracker.saveDraft({ + draft: { + activationStepIncluded: true, + }, + }); + + return { + ...draft, + activationStepIncluded: true, + }; + } + public async getOnboardingResponse(options?: { includeActivationCode?: boolean; }): Promise { @@ -356,6 +483,7 @@ export class OnboardingService implements OnModuleInit { const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; const partnerInfo = await this.getPublicPartnerInfo(); const onboardingState = await this.getOnboardingState(); + const wizardDraft = await this.getWizardDraft(state, onboardingState); const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); const isForceOpen = state.forceOpen ?? false; const isBypassed = this.onboardingTracker.isBypassed(); @@ -383,6 +511,7 @@ export class OnboardingService implements OnModuleInit { activationCode, shouldOpen: !isBypassed && (isForceOpen || shouldAutoOpen), onboardingState, + wizard: this.buildWizardState(state, onboardingState, wizardDraft), }; } @@ -399,25 +528,6 @@ export class OnboardingService implements OnModuleInit { await this.onboardingTracker.setForceOpen(true); } - public async closeOnboarding(): Promise { - const trackerStateResult = await this.onboardingTracker.getStateResult(); - if (trackerStateResult.kind === 'error') { - throw trackerStateResult.error; - } - - const state = trackerStateResult.state; - const currentVersion = this.onboardingTracker.getCurrentVersion(); - const shouldAutoOpen = this.isVersionSupported(currentVersion) && !state.completed; - - if (state.forceOpen) { - await this.onboardingTracker.setForceOpen(false); - } - - if (shouldAutoOpen) { - await this.onboardingTracker.markCompleted(); - } - } - public async bypassOnboarding(): Promise { this.onboardingTracker.setBypassActive(true); } @@ -426,6 +536,36 @@ export class OnboardingService implements OnModuleInit { this.onboardingTracker.setBypassActive(false); } + public async saveOnboardingDraft(input: SaveOnboardingDraftInput): Promise { + if (input.expectedServerName) { + // Rename flows use this guard to avoid persisting a resume step until + // the live server identity has actually switched to the expected host. + const liveServerName = getters.emhttp().nginx?.lanName?.trim() ?? ''; + if (liveServerName !== input.expectedServerName.trim()) { + this.logger.warn( + `Skipping onboarding draft save because live server name '${liveServerName}' did not match expected '${input.expectedServerName}'.` + ); + return false; + } + } + + await this.onboardingTracker.saveDraft({ + draft: input.draft, + navigation: input.navigation + ? { + currentStepId: input.navigation.currentStepId, + } + : undefined, + internalBootState: input.internalBootState + ? { + applyAttempted: input.internalBootState.applyAttempted, + applySucceeded: input.internalBootState.applySucceeded, + } + : undefined, + }); + return true; + } + public isFreshInstall(): boolean { return this.onboardingState.isFreshInstall(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index b56be8cc05..379c2f9767 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -77,11 +77,6 @@ export class OnboardingMutations { }) openOnboarding!: Onboarding; - @Field(() => Onboarding, { - description: 'Close the onboarding modal', - }) - closeOnboarding!: Onboarding; - @Field(() => Onboarding, { description: 'Temporarily bypass onboarding in API memory', }) @@ -102,6 +97,11 @@ export class OnboardingMutations { }) clearOnboardingOverride!: Onboarding; + @Field(() => Boolean, { + description: 'Persist server-owned onboarding wizard draft state', + }) + saveOnboardingDraft!: boolean; + @Field(() => OnboardingInternalBootResult, { description: 'Create and configure internal boot pool via emcmd operations', }) diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts index bd61811445..4e38054895 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -15,7 +15,10 @@ import { Min, ValidateNested, } from 'class-validator'; +import { GraphQLJSON } from 'graphql-scalars'; +import type { OnboardingDraft } from '@app/unraid-api/config/onboarding-tracker.model.js'; +import { OnboardingWizardStepId } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; @@ -286,6 +289,51 @@ export class OnboardingOverrideInput { registrationState?: RegistrationState; } +@InputType() +export class OnboardingWizardNavigationInput { + @Field(() => OnboardingWizardStepId, { nullable: true }) + @IsOptional() + @IsEnum(OnboardingWizardStepId) + currentStepId?: OnboardingWizardStepId; +} + +@InputType() +export class OnboardingWizardInternalBootStateInput { + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + applyAttempted?: boolean; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + applySucceeded?: boolean; +} + +@InputType() +export class SaveOnboardingDraftInput { + @Field(() => GraphQLJSON, { nullable: true }) + @IsOptional() + draft?: OnboardingDraft; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + expectedServerName?: string; + + @Field(() => OnboardingWizardNavigationInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardNavigationInput) + navigation?: OnboardingWizardNavigationInput; + + @Field(() => OnboardingWizardInternalBootStateInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardInternalBootStateInput) + internalBootState?: OnboardingWizardInternalBootStateInput; +} + @InputType({ description: 'Input for creating an internal boot pool during onboarding', }) diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts index bf69028236..65ed9071cd 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts @@ -3,8 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; import type { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import type { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; -import type { OnboardingOverrideInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; -import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import type { + OnboardingOverrideInput, + SaveOnboardingDraftInput, +} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; +import { + OnboardingStatus, + OnboardingWizardStepId, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { CreateInternalBootPoolInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; @@ -18,9 +24,9 @@ describe('OnboardingMutationsResolver', () => { markOnboardingCompleted: vi.fn(), resetOnboarding: vi.fn(), openOnboarding: vi.fn(), - closeOnboarding: vi.fn(), bypassOnboarding: vi.fn(), resumeOnboarding: vi.fn(), + saveOnboardingDraft: vi.fn(), getOnboardingResponse: vi.fn(), clearActivationDataCache: vi.fn(), } satisfies Pick< @@ -28,9 +34,9 @@ describe('OnboardingMutationsResolver', () => { | 'markOnboardingCompleted' | 'resetOnboarding' | 'openOnboarding' - | 'closeOnboarding' | 'bypassOnboarding' | 'resumeOnboarding' + | 'saveOnboardingDraft' | 'getOnboardingResponse' | 'clearActivationDataCache' >; @@ -56,6 +62,15 @@ describe('OnboardingMutationsResolver', () => { hasActivationCode: false, activationRequired: false, }, + wizard: { + currentStepId: 'OVERVIEW', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, }; let resolver: OnboardingMutationsResolver; @@ -72,9 +87,9 @@ describe('OnboardingMutationsResolver', () => { onboardingService.markOnboardingCompleted.mockResolvedValue(undefined); onboardingService.resetOnboarding.mockResolvedValue(undefined); onboardingService.openOnboarding.mockResolvedValue(undefined); - onboardingService.closeOnboarding.mockResolvedValue(undefined); onboardingService.bypassOnboarding.mockResolvedValue(undefined); onboardingService.resumeOnboarding.mockResolvedValue(undefined); + onboardingService.saveOnboardingDraft.mockResolvedValue(true); onboardingService.getOnboardingResponse.mockResolvedValue(defaultOnboardingResponse); resolver = createResolver(); @@ -127,18 +142,6 @@ describe('OnboardingMutationsResolver', () => { expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); }); - it('delegates closeOnboarding through the onboarding service', async () => { - const response = { - ...defaultOnboardingResponse, - shouldOpen: false, - }; - onboardingService.getOnboardingResponse.mockResolvedValue(response); - - await expect(resolver.closeOnboarding()).resolves.toEqual(response); - expect(onboardingService.closeOnboarding).toHaveBeenCalledTimes(1); - expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); - }); - it('delegates bypassOnboarding through the onboarding service', async () => { const response = { ...defaultOnboardingResponse, @@ -199,6 +202,29 @@ describe('OnboardingMutationsResolver', () => { expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith(); }); + it('delegates saveOnboardingDraft through the onboarding service', async () => { + const input: SaveOnboardingDraftInput = { + draft: { + coreSettings: { + serverName: 'Tower', + }, + plugins: { + selectedIds: ['community.applications'], + }, + }, + navigation: { + currentStepId: OnboardingWizardStepId.ADD_PLUGINS, + }, + internalBootState: { + applyAttempted: true, + applySucceeded: false, + }, + }; + + await expect(resolver.saveOnboardingDraft(input)).resolves.toBe(true); + expect(onboardingService.saveOnboardingDraft).toHaveBeenCalledWith(input); + }); + it('propagates onboarding response failures after completion', async () => { onboardingService.getOnboardingResponse.mockRejectedValue(new Error('tracker-read-failed')); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts index f16e8f524d..15f93674b4 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -14,6 +14,7 @@ import { OnboardingInternalBootContext, OnboardingInternalBootResult, OnboardingOverrideInput, + SaveOnboardingDraftInput, } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; @Resolver(() => OnboardingMutations) @@ -60,18 +61,6 @@ export class OnboardingMutationsResolver { return this.onboardingService.getOnboardingResponse(); } - @ResolveField(() => Onboarding, { - description: 'Close the onboarding modal', - }) - @UsePermissions({ - action: AuthAction.UPDATE_ANY, - resource: Resource.WELCOME, - }) - async closeOnboarding(): Promise { - await this.onboardingService.closeOnboarding(); - return this.onboardingService.getOnboardingResponse(); - } - @ResolveField(() => Onboarding, { description: 'Temporarily bypass onboarding in API memory', }) @@ -128,6 +117,17 @@ export class OnboardingMutationsResolver { return this.onboardingService.getOnboardingResponse(); } + @ResolveField(() => Boolean, { + description: 'Persist server-owned onboarding wizard draft state', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async saveOnboardingDraft(@Args('input') input: SaveOnboardingDraftInput): Promise { + return this.onboardingService.saveOnboardingDraft(input); + } + @ResolveField(() => OnboardingInternalBootResult, { description: 'Create and configure internal boot pool via emcmd operations', }) diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index f912fea200..b65a66ca32 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; import { OnboardingStateModule } from '@app/unraid-api/config/onboarding-state.module.js'; @@ -83,6 +84,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; RootMutationsResolver, ServerResolver, ServerService, + AvahiService, ServicesResolver, SharesResolver, VarsResolver, diff --git a/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts b/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts new file mode 100644 index 0000000000..d4c44ebb79 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts @@ -0,0 +1,47 @@ +import { type SliceState } from '@app/store/modules/emhttp.js'; +import { + ProfileModel, + Server, + ServerStatus, +} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; + +type BuildServerResponseOptions = { + apikey?: string; + comment?: string; + name?: string; + owner?: Partial; +}; + +const DEFAULT_OWNER: ProfileModel = { + id: 'local', + username: 'root', + url: '', + avatar: '', +}; + +export const buildServerResponse = ( + emhttpState: SliceState, + { apikey = '', comment, name, owner }: BuildServerResponseOptions = {} +): Server => { + const lanip = emhttpState.networks?.[0]?.ipaddr?.[0] ?? ''; + const port = emhttpState.var?.port ?? ''; + const defaultUrl = emhttpState.nginx?.defaultUrl?.trim() || undefined; + + return { + id: 'local', + owner: { + ...DEFAULT_OWNER, + ...owner, + }, + guid: emhttpState.var?.regGuid ?? '', + apikey, + name: name ?? emhttpState.var?.name ?? 'Local Server', + comment: comment ?? emhttpState.var?.comment, + status: ServerStatus.ONLINE, + wanip: '', + lanip, + localurl: lanip ? `http://${lanip}${port ? `:${port}` : ''}` : '', + remoteurl: '', + defaultUrl, + }; +}; diff --git a/api/src/unraid-api/graph/resolvers/servers/server.model.ts b/api/src/unraid-api/graph/resolvers/servers/server.model.ts index 0b6ddc3c70..e81214dab6 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.model.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.model.ts @@ -57,4 +57,7 @@ export class Server extends Node { @Field() remoteurl!: string; + + @Field({ nullable: true, description: 'Preferred live URL from nginx.ini defaultUrl' }) + defaultUrl?: string; } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts index 4018bfe9a5..426347bbeb 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts @@ -4,17 +4,44 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getters } from '@app/store/index.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, +})); + describe('ServersResolver', () => { let resolver: ServerResolver; + let mockConfigService: { get: ReturnType }; beforeEach(async () => { - const mockConfigService = { + mockConfigService = { get: vi.fn(), }; + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + regGuid: 'GUID-123', + name: 'Tower', + comment: 'Primary host', + port: 80, + }, + networks: [{ ipaddr: ['192.168.1.10'] }], + nginx: { + defaultUrl: 'https://Tower.local:4443', + }, + } as ReturnType); + mockConfigService.get.mockReturnValue({ + config: { + username: 'ajit', + apikey: 'api-key-123', + }, + }); + const module: TestingModule = await Test.createTestingModule({ providers: [ ServerResolver, @@ -35,4 +62,26 @@ describe('ServersResolver', () => { it('should be defined', () => { expect(resolver).toBeDefined(); }); + + it('returns the shared server shape with defaultUrl for the server query', async () => { + await expect(resolver.server()).resolves.toEqual({ + id: 'local', + owner: { + id: 'local', + username: 'ajit', + url: '', + avatar: '', + }, + guid: 'GUID-123', + apikey: 'api-key-123', + name: 'Tower', + comment: 'Primary host', + status: 'ONLINE', + wanip: '', + lanip: '192.168.1.10', + localurl: 'http://192.168.1.10:80', + remoteurl: '', + defaultUrl: 'https://Tower.local:4443', + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 65f041a560..5edfb465ee 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -8,11 +8,8 @@ import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js'; -import { - ProfileModel, - Server as ServerModel, - ServerStatus, -} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; +import { buildServerResponse } from '@app/unraid-api/graph/resolvers/servers/build-server-response.js'; +import { Server as ServerModel } from '@app/unraid-api/graph/resolvers/servers/server.model.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; @Injectable() @@ -65,35 +62,11 @@ export class ServerResolver { private getLocalServer(): ServerModel { const emhttp = getters.emhttp(); const connectConfig = this.configService.get('connect'); - - const guid = emhttp.var.regGuid; - const name = emhttp.var.name; - const comment = emhttp.var.comment; - const wanip = ''; - const lanip: string = emhttp.networks[0]?.ipaddr[0] || ''; - const port = emhttp.var?.port; - const localurl = `http://${lanip}:${port}`; - const remoteurl = ''; - - const owner: ProfileModel = { - id: 'local', - username: connectConfig?.config?.username ?? 'root', - url: '', - avatar: '', - }; - - return { - id: 'local', - owner, - guid: guid || '', + return buildServerResponse(emhttp, { apikey: connectConfig?.config?.apikey ?? '', - name: name ?? 'Local Server', - comment, - status: ServerStatus.ONLINE, - wanip, - lanip, - localurl, - remoteurl, - }; + owner: { + username: connectConfig?.config?.username ?? 'root', + }, + }); } } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts index 2fc2f1e287..683b89da13 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts @@ -6,9 +6,10 @@ import { GraphQLError } from 'graphql'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { getters } from '@app/store/index.js'; +import { getters, store } from '@app/store/index.js'; import { type SliceState } from '@app/store/modules/emhttp.js'; import { FileLoadStatus } from '@app/store/types.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; @@ -21,6 +22,9 @@ vi.mock('@app/store/index.js', () => ({ emhttp: vi.fn(), paths: vi.fn(), }, + store: { + dispatch: vi.fn(), + }, })); const createEmhttpState = ({ @@ -30,6 +34,9 @@ const createEmhttpState = ({ fsState = 'Stopped', mdState, sslEnabled = true, + defaultUrl = 'https://Tower.local:4443', + lanMdns = 'Tower.local', + lanName = 'tower.local', }: { name?: string; comment?: string; @@ -37,6 +44,9 @@ const createEmhttpState = ({ fsState?: string; mdState?: SliceState['var']['mdState']; sslEnabled?: boolean; + defaultUrl?: string; + lanMdns?: string; + lanName?: string; } = {}): SliceState => ({ status: FileLoadStatus.LOADED, var: { @@ -52,8 +62,10 @@ const createEmhttpState = ({ networks: [{ ipaddr: ['192.168.1.10'] }] as unknown as SliceState['networks'], nginx: { sslEnabled, - lanName: 'tower.local', + defaultUrl, lanIp: '192.168.1.10', + lanMdns, + lanName, } as unknown as SliceState['nginx'], shares: [], disks: [], @@ -64,12 +76,16 @@ const createEmhttpState = ({ describe('ServerService', () => { let service: ServerService; + let avahiService: { restart: ReturnType }; let tempDirectory: string; let identConfigPath: string; beforeEach(async () => { vi.clearAllMocks(); - service = new ServerService(); + avahiService = { + restart: vi.fn().mockResolvedValue(undefined), + }; + service = new ServerService(avahiService as unknown as AvahiService); tempDirectory = await mkdtemp(join(tmpdir(), 'server-service-')); identConfigPath = join(tempDirectory, 'boot/config/ident.cfg'); @@ -77,6 +93,9 @@ describe('ServerService', () => { vi.mocked(getters.paths).mockReturnValue({ identConfig: identConfigPath, } as ReturnType); + vi.mocked(store.dispatch).mockReturnValue({ + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType); await mkdir(join(tempDirectory, 'boot/config'), { recursive: true }); await writeFile( @@ -172,6 +191,24 @@ describe('ServerService', () => { mdState: ArrayState.STOPPED, }) ); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'NewTower', + comment: 'desc', + sysModel: '', + fsState: 'Started', + mdState: ArrayState.STOPPED, + defaultUrl: 'https://NewTower.local:4443', + lanMdns: 'NewTower.local', + lanName: 'NewTower', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); await expect(service.updateServerIdentity('NewTower', 'desc')).resolves.toMatchObject({ name: 'NewTower', @@ -187,6 +224,22 @@ describe('ServerService', () => { ); return { ok: true } as Awaited>; }); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'Test1e', + comment: 'Test server1e', + sysModel: 'Model X200', + defaultUrl: 'https://Test1e.local:4443', + lanMdns: 'Test1e.local', + lanName: 'Test1e', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); const result = await service.updateServerIdentity('Test1e', 'Test server1e', 'Model X200'); @@ -224,6 +277,7 @@ describe('ServerService', () => { lanip: '192.168.1.10', localurl: 'http://192.168.1.10:80', remoteurl: '', + defaultUrl: 'https://Test1e.local:4443', }); }); @@ -236,6 +290,22 @@ describe('ServerService', () => { ); return { ok: true } as Awaited>; }); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'TowerRenamed', + comment: 'Tower comment', + sysModel: 'Model X100', + defaultUrl: 'https://TowerRenamed.local:4443', + lanMdns: 'TowerRenamed.local', + lanName: 'TowerRenamed', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); await service.updateServerIdentity('TowerRenamed'); @@ -301,6 +371,87 @@ describe('ServerService', () => { name: 'Tower', comment: 'Primary host', }); + expect(avahiService.restart).not.toHaveBeenCalled(); + }); + + it('restarts Avahi, refreshes nginx state, and returns live defaultUrl after a name change', async () => { + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + defaultUrl: 'https://Test1e.local:4443', + lanMdns: 'Test1e.local', + lanName: 'Test1e', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); + + const result = await service.updateServerIdentity('Test1e', 'Primary host'); + + expect(avahiService.restart).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + name: 'Test1e', + comment: 'Primary host', + defaultUrl: 'https://Test1e.local:4443', + }); + }); + + it('skips Avahi restart and nginx refresh when only the comment changes', async () => { + const result = await service.updateServerIdentity('Tower', 'Primary host'); + + expect(avahiService.restart).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + name: 'Tower', + comment: 'Primary host', + defaultUrl: 'https://Tower.local:4443', + }); + }); + + it('fails when Avahi restart fails after ident.cfg has been updated', async () => { + avahiService.restart.mockRejectedValue(new Error('avahi restart failed')); + + await expect(service.updateServerIdentity('Test1e', 'Primary host')).rejects.toMatchObject({ + message: 'Failed to update server identity', + extensions: { + cause: 'avahi restart failed', + persistedIdentity: { + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + }, + }, + }); + }); + + it('fails when live nginx state stays stale after Avahi restart', async () => { + vi.mocked(store.dispatch).mockReturnValue({ + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType); + + await expect(service.updateServerIdentity('Test1e', 'Primary host')).rejects.toMatchObject({ + message: 'Failed to update server identity', + extensions: { + cause: 'Live network identity did not converge after Avahi restart', + persistedIdentity: { + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + }, + liveIdentity: { + lanName: 'tower.local', + lanMdns: 'Tower.local', + defaultUrl: 'https://Tower.local:4443', + }, + }, + }); }); it('throws generic failure when emcmd fails and ident.cfg stays unchanged', async () => { diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.ts index 85ab7297ec..6896a3ae7e 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.ts @@ -5,18 +5,20 @@ import { GraphQLError } from 'graphql'; import * as ini from 'ini'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { getters } from '@app/store/index.js'; +import { getters, store } from '@app/store/index.js'; +import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; +import { StateFileKey } from '@app/store/types.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; -import { - ProfileModel, - Server, - ServerStatus, -} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; +import { buildServerResponse } from '@app/unraid-api/graph/resolvers/servers/build-server-response.js'; +import { Server } from '@app/unraid-api/graph/resolvers/servers/server.model.js'; @Injectable() export class ServerService { private readonly logger = new Logger(ServerService.name); + constructor(private readonly avahiService: AvahiService) {} + private async readPersistedIdentity(): Promise<{ name: string; comment: string; @@ -59,36 +61,64 @@ export class ServerService { }; } - private buildServerResponse( - emhttpState: ReturnType, - name: string, - comment: string - ): Server { - const guid = emhttpState.var?.regGuid ?? ''; - const lanip = emhttpState.networks?.[0]?.ipaddr?.[0] ?? ''; - const port = emhttpState.var?.port ?? ''; - const owner: ProfileModel = { - id: 'local', - username: 'root', - url: '', - avatar: '', - }; - + private getLiveIdentityState(emhttpState: ReturnType) { return { - id: 'local', - owner, - guid, - apikey: '', - name, - comment, - status: ServerStatus.ONLINE, - wanip: '', - lanip, - localurl: lanip ? `http://${lanip}${port ? `:${port}` : ''}` : '', - remoteurl: '', + lanName: emhttpState.nginx?.lanName ?? '', + lanMdns: emhttpState.nginx?.lanMdns ?? '', + defaultUrl: emhttpState.nginx?.defaultUrl?.trim() ?? '', }; } + private async refreshNginxStateAfterNameChange( + name: string, + persistedIdentity: Awaited> + ): Promise> { + try { + await this.avahiService.restart(); + } catch (error) { + this.logger.error('Failed to restart Avahi after server rename', error as Error); + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: + error instanceof Error && error.message + ? error.message + : 'Avahi restart failed after ident.cfg update', + persistedIdentity, + }, + }); + } + + try { + await store.dispatch(loadSingleStateFile(StateFileKey.nginx)).unwrap(); + } catch (error) { + this.logger.error('Failed to reload nginx state after server rename', error as Error); + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: + error instanceof Error && error.message + ? error.message + : 'Failed to reload nginx.ini after Avahi restart', + persistedIdentity, + }, + }); + } + + const refreshedEmhttp = getters.emhttp(); + const liveIdentity = this.getLiveIdentityState(refreshedEmhttp); + + if (liveIdentity.lanName !== name || !liveIdentity.defaultUrl) { + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: 'Live network identity did not converge after Avahi restart', + persistedIdentity, + liveIdentity, + }, + }); + } + + return refreshedEmhttp; + } + /** * Updates the server identity (name and comment/description). * The array must be stopped to change the server name. @@ -141,7 +171,10 @@ export class ServerService { if (name === currentName && nextComment === currentComment && nextSysModel === currentSysModel) { this.logger.log('Server identity unchanged; skipping emcmd update.'); - return this.buildServerResponse(currentEmhttp, currentName, currentComment); + return buildServerResponse(currentEmhttp, { + comment: currentComment, + name: currentName, + }); } if (name !== currentName) { @@ -189,8 +222,15 @@ export class ServerService { ); } - const latestEmhttp = getters.emhttp(); - return this.buildServerResponse(latestEmhttp, name, nextComment); + const latestEmhttp = + name !== currentName + ? await this.refreshNginxStateAfterNameChange(name, persistedIdentity) + : getters.emhttp(); + + return buildServerResponse(latestEmhttp, { + comment: nextComment, + name, + }); } catch (error) { if (error instanceof GraphQLError) { throw error; diff --git a/docs/onboarding-internal-boot-port-differences.md b/docs/onboarding-internal-boot-port-differences.md deleted file mode 100644 index 4aa778d171..0000000000 --- a/docs/onboarding-internal-boot-port-differences.md +++ /dev/null @@ -1,25 +0,0 @@ -# Onboarding Internal Boot Port: Non-1:1 Differences - -This list tracks behavior that is intentionally or currently different from the webgui implementation in commit `edff0e5202c0efeaa613efb8dfc599453d0fe5cb`. - -## Current Differences - -- Data source for setup context: - - Webgui: `Main/PoolDevices`/websocket-rendered context + `devs.ini`-based behavior. - - Onboarding: existing GraphQL (`array`, `vars`, `shares`, `disks`). - -- Device option labeling: - - Webgui formats labels via PHP helpers. - - Onboarding formats labels in Vue (` - ()`). - -- Dialog auto-open via URL flag: - - Webgui supports `?createbootpool`. - - Onboarding step does not support URL-triggered auto-open. - -- Reboot flow: - - Webgui dialog path is "Activate and Reboot" in one flow. - - Onboarding applies `mkbootpool` without reboot in summary, then exposes reboot as a separate next-step action. - -- Internal-boot visibility source: - - Onboarding hides the step using `vars.enableBootTransfer` (`no` means already internal boot). - - This matches `var.ini` semantics, but is still API-driven rather than websocket-rendered page context. diff --git a/docs/onboarding-internal-boot.md b/docs/onboarding-internal-boot.md new file mode 100644 index 0000000000..6f395b6aff --- /dev/null +++ b/docs/onboarding-internal-boot.md @@ -0,0 +1,127 @@ +# Onboarding Internal Boot + +## Overview + +The internal-boot step is part of the main onboarding wizard, but it still has its own operational state because it can perform real disk actions. + +The step uses two layers of state: + +- `draft.internalBoot` + - user intent that should survive resume +- `internalBootState` + - operational result flags used by Summary and Next Steps + +Both are stored in `onboarding-tracker.json`, but only the draft is user-entered data. + +## Visibility + +Internal boot is still server-gated. + +`api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts` shows `CONFIGURE_BOOT` only when: + +- `getters.emhttp().var.enableBootTransfer === 'yes'` + +That means the step is recomputed on every bootstrap/read instead of being frozen in the tracker. + +## Saved Shape + +The persisted internal-boot draft lives under: + +```json +{ + "draft": { + "internalBoot": { + "bootMode": "storage", + "skipped": false, + "selection": { + "poolName": "cache", + "slotCount": 2, + "devices": [ + { + "id": "disk1", + "sizeBytes": 512110190592, + "deviceName": "sda" + }, + { + "id": "disk2", + "sizeBytes": 512110190592, + "deviceName": "sdb" + } + ], + "bootSizeMiB": 32768, + "updateBios": true, + "poolMode": "hybrid" + } + } + }, + "internalBootState": { + "applyAttempted": true, + "applySucceeded": false + } +} +``` + +`draft.internalBoot` means: + +- `bootMode` + - `usb` or `storage` +- `skipped` + - whether the user explicitly skipped the step +- `selection` + - the storage pool choice when boot moves off USB + - selected devices are persisted as `{ id, sizeBytes, deviceName }` objects so the summary views can render stable labels without re-fetching disk metadata + +`internalBootState` means: + +- `applyAttempted` +- `applySucceeded` + +Those flags are separate so the draft stays about user intent, not execution bookkeeping. + +## Frontend Flow + +Main wizard usage: + +- `web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue` +- `web/src/components/Onboarding/steps/OnboardingSummaryStep.vue` +- `web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue` + +Standalone/admin flow: + +- `web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue` + +The step component now takes `initialDraft` and emits committed snapshots back to the modal instead of mutating a shared Pinia draft store. + +## Persistence Rules + +Internal-boot choices are saved only when the user leaves the step or commits a downstream step transition. + +That includes: + +- continuing from `CONFIGURE_BOOT` +- skipping `CONFIGURE_BOOT` +- navigating back from later steps after internal-boot changes + +Summary updates `internalBootState` after apply attempts so Next Steps can render the correct follow-up actions. + +## Current Behavior Notes + +- The step is hidden as soon as the server no longer qualifies for internal boot transfer on the next bootstrap/read. +- The wizard intentionally keeps the current 1:1 behavior where externally changed system state can remove the step after a reopen. +- A future session-only sticky-step behavior can be added separately without changing the durable tracker model. + +## Testing + +Automated coverage: + +- `web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts` +- `web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts` +- `api/src/unraid-api/config/onboarding-tracker.service.spec.ts` +- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts` + +Recommended manual checks: + +1. Start on a server with `enableBootTransfer=yes` and confirm `CONFIGURE_BOOT` appears. +2. Choose storage-backed boot, continue, refresh, and confirm the step resumes from server state. +3. Apply internal boot from Summary and confirm `internalBootState` drives the post-apply UI. +4. Reopen onboarding on a server that no longer qualifies and confirm the step is omitted from `visibleStepIds`. diff --git a/web/__test__/components/Onboarding/InternalBootConfirmDialog.test.ts b/web/__test__/components/Onboarding/InternalBootConfirmDialog.test.ts new file mode 100644 index 0000000000..6ec1e150a6 --- /dev/null +++ b/web/__test__/components/Onboarding/InternalBootConfirmDialog.test.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it } from 'vitest'; + +import InternalBootConfirmDialog from '~/components/Onboarding/components/InternalBootConfirmDialog.vue'; +import { createTestI18n } from '../../utils/i18n'; + +describe('InternalBootConfirmDialog', () => { + it('explains USB licensing behavior and links to the TPM licensing FAQ', () => { + const alertStub = { + props: ['description'], + template: '
{{ description }}
', + }; + const buttonStub = { + template: '', + }; + const iconStub = { + template: '', + }; + const modalStub = { + props: ['open', 'title', 'description'], + template: ` +
+

{{ title }}

+

{{ description }}

+ + +
+ `, + }; + + const wrapper = mount(InternalBootConfirmDialog, { + props: { + open: true, + action: 'reboot', + }, + global: { + plugins: [createTestI18n()], + stubs: { + Alert: alertStub, + Button: buttonStub, + Icon: iconStub, + Modal: modalStub, + UAlert: alertStub, + UButton: buttonStub, + UIcon: iconStub, + UModal: modalStub, + }, + }, + }); + + expect(wrapper.text()).toContain('To complete internal boot setup'); + expect(wrapper.text()).toContain('please do NOT remove your Unraid flash drive'); + expect(wrapper.text()).toContain( + "Switching to internal boot doesn't automatically move your license" + ); + expect(wrapper.text()).toContain( + 'If your license is still linked to the USB, it must remain connected.' + ); + expect(wrapper.findAll('br')).toHaveLength(2); + expect(wrapper.get('strong').text()).toBe('Want to ditch the USB entirely?'); + expect(wrapper.text()).toContain('Once switched, the USB drive will no longer be required'); + expect(wrapper.text()).toContain('Learn about TPM licensing'); + expect(wrapper.get('a').attributes('href')).toBe( + 'https://docs.unraid.net/unraid-os/troubleshooting/tpm-licensing-faq/' + ); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts b/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts new file mode 100644 index 0000000000..af7f0ffdb4 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts @@ -0,0 +1,199 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it } from 'vitest'; + +import type { BootConfigurationSummaryLabels } from '~/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel'; + +import { buildBootConfigurationSummaryViewModel } from '~/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel'; +import OnboardingBootConfigurationSummary from '~/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue'; + +const labels: BootConfigurationSummaryLabels = { + title: 'Boot Configuration', + bootMethod: 'Boot Method', + bootMethodStorage: 'Storage Drive(s)', + bootMethodUsb: 'USB/Flash Drive', + poolMode: 'Pool mode', + poolModeDedicated: 'Dedicated boot pool', + poolModeHybrid: 'Boot + data pool', + pool: 'Pool', + slots: 'Boot devices', + bootReserved: 'Boot Reserved', + updateBios: 'Update BIOS', + devices: 'Devices', + yes: 'Yes', + no: 'No', +}; + +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + +describe('OnboardingBootConfigurationSummary', () => { + it('builds a hidden result when boot configuration was skipped', () => { + expect( + buildBootConfigurationSummaryViewModel( + { + bootMode: 'usb', + skipped: true, + selection: null, + }, + { + labels, + formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`, + formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`, + } + ) + ).toEqual({ kind: 'hidden' }); + }); + + it('builds a usb summary without storage rows', () => { + const result = buildBootConfigurationSummaryViewModel( + { + bootMode: 'usb', + skipped: false, + selection: null, + }, + { + labels, + formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`, + formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`, + } + ); + + expect(result).toEqual({ + kind: 'ready', + summary: { + title: 'Boot Configuration', + rows: [ + { + key: 'bootMethod', + label: 'Boot Method', + value: 'USB/Flash Drive', + }, + ], + devicesLabel: 'Devices', + devices: [], + }, + }); + }); + + it('builds a hybrid storage summary and falls back to raw device ids when labels are missing', () => { + const result = buildBootConfigurationSummaryViewModel( + { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [ + createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'), + createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'), + ], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + { + labels, + formatBootSize: (bootSizeMiB) => `${bootSizeMiB / 1024} GB`, + formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`, + } + ); + + expect(result).toEqual({ + kind: 'ready', + summary: { + title: 'Boot Configuration', + rows: [ + { + key: 'bootMethod', + label: 'Boot Method', + value: 'Storage Drive(s)', + }, + { + key: 'poolMode', + label: 'Pool mode', + value: 'Boot + data pool', + }, + { + key: 'pool', + label: 'Pool', + value: 'cache', + }, + { + key: 'slots', + label: 'Boot devices', + value: '2', + }, + { + key: 'bootReserved', + label: 'Boot Reserved', + value: '16 GB', + }, + { + key: 'updateBios', + label: 'Update BIOS', + value: 'Yes', + }, + ], + devicesLabel: 'Devices', + devices: [ + { id: 'DISK-A', label: 'DISK-A - 537 GB (sda)' }, + { id: 'DISK-B', label: 'DISK-B - 268 GB (sdb)' }, + ], + }, + }); + }); + + it('returns an invalid result for incomplete storage selections', () => { + expect( + buildBootConfigurationSummaryViewModel( + { + bootMode: 'storage', + skipped: false, + selection: { + poolName: '', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + { + labels, + formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`, + formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`, + missingStorageSelectionBehavior: 'invalid', + } + ) + ).toEqual({ + kind: 'invalid', + reason: 'INCOMPLETE_STORAGE_SELECTION', + }); + }); + + it('renders the shared boot summary card', () => { + const wrapper = mount(OnboardingBootConfigurationSummary, { + props: { + summary: { + title: 'Boot Configuration', + rows: [ + { key: 'bootMethod', label: 'Boot Method', value: 'Storage Drive(s)' }, + { key: 'slots', label: 'Boot devices', value: '2' }, + ], + devicesLabel: 'Devices', + devices: [{ id: 'DISK-A', label: 'DISK-A - 537 GB (sda)' }], + }, + }, + }); + + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Boot Configuration'); + expect(wrapper.text()).toContain('Storage Drive(s)'); + expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)'); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts index c8c689566c..e4681b15a4 100644 --- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts @@ -13,11 +13,16 @@ const { onboardingStore, setCoreSettingsMock, timeZoneOptionsResult, + timeZoneOptionsError, + refetchTimeZoneOptionsMock, languagesResult, languagesLoading, languagesError, + refetchLanguagesMock, coreOnResultHandlers, coreSettingsResult, + coreSettingsError, + refetchCoreSettingsMock, useQueryMock, } = vi.hoisted(() => ({ setCoreSettingsMock: vi.fn(), @@ -43,6 +48,8 @@ const { ], }, }, + timeZoneOptionsError: { value: null as unknown }, + refetchTimeZoneOptionsMock: vi.fn().mockResolvedValue(undefined), languagesResult: { value: { customization: { @@ -55,8 +62,11 @@ const { }, languagesLoading: { value: false }, languagesError: { value: null as unknown }, + refetchLanguagesMock: vi.fn().mockResolvedValue(undefined), coreOnResultHandlers: [] as Array<(payload: unknown) => void>, coreSettingsResult: { value: null as unknown }, + coreSettingsError: { value: null as unknown }, + refetchCoreSettingsMock: vi.fn().mockResolvedValue(undefined), useQueryMock: vi.fn(), })); @@ -69,6 +79,10 @@ vi.mock('@unraid/ui', () => ({ template: '', }, + Spinner: { + name: 'Spinner', + template: '
', + }, })); vi.mock('@vvo/tzdb', () => ({ @@ -88,10 +102,6 @@ vi.mock('@vvo/tzdb', () => ({ ], })); -vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => draftStore, -})); - vi.mock('@/components/Onboarding/store/onboardingStatus', () => ({ useOnboardingStore: () => onboardingStore, })); @@ -108,11 +118,16 @@ vi.mock('@vue/apollo-composable', async () => { const setupQueryMocks = () => { useQueryMock.mockImplementation((doc: unknown) => { if (doc === TIME_ZONE_OPTIONS_QUERY) { - return { result: timeZoneOptionsResult }; + return { + result: timeZoneOptionsResult, + error: timeZoneOptionsError, + refetch: refetchTimeZoneOptionsMock, + }; } if (doc === GET_CORE_SETTINGS_QUERY) { return { result: coreSettingsResult, + error: coreSettingsError, onResult: (cb: (payload: unknown) => void) => { coreOnResultHandlers.push((payload: unknown) => { const candidate = payload as { data?: unknown }; @@ -120,6 +135,7 @@ const setupQueryMocks = () => { cb(payload); }); }, + refetch: refetchCoreSettingsMock, }; } if (doc === GET_AVAILABLE_LANGUAGES_QUERY) { @@ -127,6 +143,7 @@ const setupQueryMocks = () => { result: languagesResult, loading: languagesLoading, error: languagesError, + refetch: refetchLanguagesMock, }; } return { result: { value: null } }; @@ -134,10 +151,21 @@ const setupQueryMocks = () => { }; const mountComponent = (props: Record = {}) => { - const onComplete = vi.fn(); + const onComplete = setCoreSettingsMock; const wrapper = mount(OnboardingCoreSettingsStep, { props: { + initialDraft: draftStore.coreSettingsInitialized + ? { + serverName: draftStore.serverName, + serverDescription: draftStore.serverDescription, + timeZone: draftStore.selectedTimeZone, + theme: draftStore.selectedTheme, + language: draftStore.selectedLanguage, + useSsh: draftStore.useSsh, + } + : null, onComplete, + onCloseOnboarding: vi.fn(), showBack: true, ...props, }, @@ -193,12 +221,48 @@ describe('OnboardingCoreSettingsStep', () => { draftStore.coreSettingsInitialized = false; onboardingStore.completed.value = true; onboardingStore.loading.value = false; - coreSettingsResult.value = null; + coreSettingsResult.value = { + server: { name: 'Tower', comment: '' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, + }; + coreSettingsError.value = null; + timeZoneOptionsError.value = null; languagesLoading.value = false; languagesError.value = null; }); + it('blocks the form behind a loading gate until the required queries are ready', async () => { + coreSettingsResult.value = null; + const { wrapper } = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.find('input').exists()).toBe(false); + }); + + it('shows retry and close actions when a required query errors', async () => { + const onCloseOnboarding = vi.fn(); + coreSettingsResult.value = null; + coreSettingsError.value = new Error('offline'); + + const { wrapper } = mountComponent({ onCloseOnboarding }); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true); + + await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click'); + await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click'); + + expect(refetchTimeZoneOptionsMock).toHaveBeenCalledTimes(1); + expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(refetchLanguagesMock).toHaveBeenCalledTimes(1); + expect(onCloseOnboarding).toHaveBeenCalledTimes(1); + }); + it('prefers browser timezone over API on initial setup when draft timezone is empty', async () => { onboardingStore.completed.value = false; @@ -235,6 +299,8 @@ describe('OnboardingCoreSettingsStep', () => { it('prefers non-empty draft timezone over browser and API on initial setup', async () => { onboardingStore.completed.value = false; + draftStore.coreSettingsInitialized = true; + draftStore.serverName = 'Tower'; draftStore.selectedTimeZone = 'UTC'; const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( @@ -574,23 +640,15 @@ describe('OnboardingCoreSettingsStep', () => { expect(onComplete).toHaveBeenCalledTimes(1); }); - it('uses trusted defaults when API baseline is unavailable', async () => { + it('blocks submission when API baseline is unavailable', async () => { + coreSettingsResult.value = null; const { wrapper, onComplete } = mountComponent(); await flushPromises(); - const submitButton = wrapper.find('[data-testid="brand-button"]'); - await submitButton.trigger('click'); - await flushPromises(); - - expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); - const payload = setCoreSettingsMock.mock.calls[0][0]; - expect(payload.serverName).toBe('Tower'); - expect(payload.serverDescription).toBe(''); - expect(payload.theme).toBe('white'); - expect(payload.language).toBe('en_US'); - expect(typeof payload.timeZone).toBe('string'); - expect(payload.timeZone.length).toBeGreaterThan(0); - expect(onComplete).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="brand-button"]').exists()).toBe(false); + expect(setCoreSettingsMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); }); it('blocks submission with invalid server name', async () => { diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts index 88cf46559a..d469c5b2d0 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts @@ -1,4 +1,3 @@ -import { reactive } from 'vue'; import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,12 +7,30 @@ import type { InternalBootApplyResult, InternalBootSelection, } from '~/components/Onboarding/composables/internalBoot'; +import type { OnboardingInternalBootDraft } from '~/components/Onboarding/onboardingWizardState'; import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue'; import { createTestI18n } from '../../utils/i18n'; const INTERNAL_BOOT_HISTORY_STATE_KEY = '__unraidOnboardingInternalBoot'; +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + return { promise, resolve, reject }; +}; + type InternalBootHistoryState = { sessionId: string; stepId: 'CONFIGURE_BOOT' | 'SUMMARY'; @@ -21,8 +38,7 @@ type InternalBootHistoryState = { }; const { - draftStore, - reactiveStoreRef, + configureDraftState, applyInternalBootSelectionMock, submitInternalBootRebootMock, submitInternalBootShutdownMock, @@ -30,53 +46,28 @@ const { dialogPropsRef, stepPropsRef, stepperPropsRef, -} = vi.hoisted(() => { - const reactiveRef: { value: Record | null } = { value: null }; - const store = { - internalBootSelection: null as { - poolName: string; - slotCount: number; - devices: string[]; - bootSizeMiB: number; - updateBios: boolean; - poolMode: 'dedicated' | 'hybrid'; - } | null, - internalBootApplySucceeded: false, - internalBootApplyAttempted: false, - setInternalBootApplySucceeded: vi.fn((value: boolean) => { - if (reactiveRef.value) { - reactiveRef.value.internalBootApplySucceeded = value; - } else { - store.internalBootApplySucceeded = value; - } - }), - setInternalBootApplyAttempted: vi.fn((value: boolean) => { - if (reactiveRef.value) { - reactiveRef.value.internalBootApplyAttempted = value; - } else { - store.internalBootApplyAttempted = value; - } - }), - }; - - return { - draftStore: store, - reactiveStoreRef: reactiveRef, - submitInternalBootRebootMock: vi.fn(), - submitInternalBootShutdownMock: vi.fn(), - applyInternalBootSelectionMock: - vi.fn< - ( - selection: InternalBootSelection, - messages: InternalBootApplyMessages - ) => Promise - >(), - cleanupOnboardingStorageMock: vi.fn(), - dialogPropsRef: { value: null as Record | null }, - stepPropsRef: { value: null as Record | null }, - stepperPropsRef: { value: null as Record | null }, - }; -}); +} = vi.hoisted(() => ({ + configureDraftState: { + value: { + bootMode: 'usb', + skipped: true, + selection: null, + } as OnboardingInternalBootDraft, + }, + submitInternalBootRebootMock: vi.fn(), + submitInternalBootShutdownMock: vi.fn(), + applyInternalBootSelectionMock: + vi.fn< + ( + selection: InternalBootSelection, + messages: InternalBootApplyMessages + ) => Promise + >(), + cleanupOnboardingStorageMock: vi.fn(), + dialogPropsRef: { value: null as Record | null }, + stepPropsRef: { value: null as Record | null }, + stepperPropsRef: { value: null as Record | null }, +})); vi.mock('@unraid/ui', () => ({ Dialog: { @@ -87,7 +78,7 @@ vi.mock('@unraid/ui', () => ({ return { props }; }, template: ` -
+
@@ -95,13 +86,6 @@ vi.mock('@unraid/ui', () => ({ }, })); -const reactiveDraftStore = reactive(draftStore); -reactiveStoreRef.value = reactiveDraftStore; - -vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => reactiveDraftStore, -})); - vi.mock('@/components/Onboarding/composables/internalBoot', () => ({ applyInternalBootSelection: applyInternalBootSelectionMock, submitInternalBootReboot: submitInternalBootRebootMock, @@ -126,24 +110,41 @@ vi.mock('@/components/Onboarding/OnboardingSteps.vue', () => ({ stepperPropsRef.value = props; return { props }; }, - template: ` -
- {{ props.activeStepIndex }} -
- `, + template: '
{{ props.activeStepIndex }}
', }, })); vi.mock('@/components/Onboarding/steps/OnboardingInternalBootStep.vue', () => ({ default: { - props: ['onComplete', 'showBack', 'showSkip', 'isSavingStep'], + props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'showSkip', 'isSavingStep'], setup(props: Record) { + const cloneDraft = () => { + const initialDraft = configureDraftState.value; + return { + bootMode: initialDraft?.bootMode ?? 'usb', + skipped: initialDraft?.skipped ?? true, + selection: + initialDraft?.selection === undefined + ? undefined + : initialDraft.selection === null + ? null + : { + ...initialDraft.selection, + devices: (initialDraft.selection.devices ?? []).map((device) => ({ ...device })), + }, + }; + }; stepPropsRef.value = props; - return { props }; + return { props, cloneDraft }; }, template: `
- +
`, }, @@ -202,15 +203,33 @@ const dispatchPopstate = (state: Record | null) => { window.dispatchEvent(new PopStateEvent('popstate', { state })); }; +const findButtonByText = (wrapper: ReturnType, text: string) => + wrapper + .findAll('button') + .find((button) => button.text().trim().toLowerCase() === text.trim().toLowerCase()); + +const advanceToSummary = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); + await flushPromises(); +}; + +const confirmAndApply = async (wrapper: ReturnType) => { + const confirmButton = findButtonByText(wrapper, 'Confirm & Apply'); + expect(confirmButton).toBeTruthy(); + await confirmButton!.trigger('click'); + await flushPromises(); +}; + describe('OnboardingInternalBoot.standalone.vue', () => { beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); window.history.replaceState(null, '', window.location.href); - reactiveDraftStore.internalBootSelection = null; - reactiveDraftStore.internalBootApplySucceeded = false; - reactiveDraftStore.internalBootApplyAttempted = false; + configureDraftState.value = { + bootMode: 'usb', + skipped: true, + selection: null, + }; dialogPropsRef.value = null; stepPropsRef.value = null; stepperPropsRef.value = null; @@ -239,6 +258,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => { showBack: false, showSkip: false, isSavingStep: false, + initialDraft: configureDraftState.value, }); expect(stepperPropsRef.value).toMatchObject({ activeStepIndex: 0, @@ -252,11 +272,12 @@ describe('OnboardingInternalBoot.standalone.vue', () => { it('treats no selection as a no-op success without calling apply helper', async () => { const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(false); + await confirmAndApply(wrapper); expect(applyInternalBootSelectionMock).not.toHaveBeenCalled(); - expect(draftStore.setInternalBootApplySucceeded).toHaveBeenCalledWith(false); expect(wrapper.text()).toContain('No Updates Needed'); expect(wrapper.text()).toContain('No changes needed. Skipping configuration updates.'); expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true); @@ -265,31 +286,58 @@ describe('OnboardingInternalBoot.standalone.vue', () => { }); }); + it('ignores stale storage selection data when the current draft is usb', async () => { + configureDraftState.value = { + bootMode: 'usb', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }; + + const wrapper = mountComponent(); + + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); + + expect(applyInternalBootSelectionMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('No Updates Needed'); + }); + it('applies the selected internal boot configuration and records success', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: true, - poolMode: 'hybrid', + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, }; applyInternalBootSelectionMock.mockResolvedValue({ applySucceeded: true, hadWarnings: false, hadNonOptimisticFailures: false, - logs: [ - { - message: 'Internal boot pool configured.', - type: 'success', - }, - ], + logs: [{ message: 'Internal boot pool configured.', type: 'success' }], }); const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Boot Configuration'); + expect(wrapper.text()).toContain('Storage Drive(s)'); + expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)'); + await confirmAndApply(wrapper); expect(applyInternalBootSelectionMock).toHaveBeenCalledWith( { @@ -307,42 +355,72 @@ describe('OnboardingInternalBoot.standalone.vue', () => { biosUnverified: expect.any(String), } ); - expect(draftStore.setInternalBootApplySucceeded).toHaveBeenNthCalledWith(1, false); - expect(draftStore.setInternalBootApplySucceeded).toHaveBeenNthCalledWith(2, true); expect(wrapper.find('[data-testid="onboarding-console"]').exists()).toBe(true); expect(wrapper.text()).toContain('Internal boot pool configured.'); expect(wrapper.text()).toContain('Setup Applied'); expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true); expect(stepperPropsRef.value).toMatchObject({ activeStepIndex: 1, }); }); - it('shows locked failure result with reboot button when apply fails', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: false, - poolMode: 'hybrid', + it('shows an editable error state when the standalone boot summary is invalid', async () => { + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: '', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }; + + const wrapper = mountComponent(); + + await advanceToSummary(wrapper); + await flushPromises(); + + expect(wrapper.find('[data-testid="internal-boot-summary-invalid"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Your boot configuration is incomplete.'); + expect(findButtonByText(wrapper, 'Confirm & Apply')).toBeFalsy(); + + await findButtonByText(wrapper, 'Back')!.trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true); + expect(stepperPropsRef.value).toMatchObject({ + activeStepIndex: 0, + }); + }); + + it('shows a locked failure result with reboot controls when apply fails', async () => { + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: false, + poolMode: 'hybrid', + }, }; applyInternalBootSelectionMock.mockResolvedValue({ applySucceeded: false, hadWarnings: true, hadNonOptimisticFailures: true, - logs: [ - { - message: 'Internal boot setup returned an error: mkbootpool failed', - type: 'error', - }, - ], + logs: [{ message: 'Internal boot setup returned an error: mkbootpool failed', type: 'error' }], }); const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); expect(wrapper.text()).toContain('Setup Failed'); expect(wrapper.text()).toContain('Internal boot setup returned an error: mkbootpool failed'); @@ -353,8 +431,8 @@ describe('OnboardingInternalBoot.standalone.vue', () => { it('restores the configure step when browser back leaves a reversible result', async () => { const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); const currentHistoryState = getInternalBootHistoryState(); expect(currentHistoryState).toMatchObject({ @@ -370,7 +448,6 @@ describe('OnboardingInternalBoot.standalone.vue', () => { }, }); await flushPromises(); - await wrapper.vm.$nextTick(); expect(stepperPropsRef.value).toMatchObject({ activeStepIndex: 0, @@ -380,19 +457,23 @@ describe('OnboardingInternalBoot.standalone.vue', () => { it('blocks browser back navigation when locked after a fully applied result', async () => { const forwardSpy = vi.spyOn(window.history, 'forward').mockImplementation(() => {}); - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: true, - poolMode: 'hybrid', + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, }; const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); const currentHistoryState = getInternalBootHistoryState(); expect(currentHistoryState).toMatchObject({ @@ -414,12 +495,12 @@ describe('OnboardingInternalBoot.standalone.vue', () => { expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(true); }); - it('closes locally after showing a result', async () => { + it('closes locally after showing a reversible result', async () => { const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {}); const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click'); await flushPromises(); @@ -428,24 +509,11 @@ describe('OnboardingInternalBoot.standalone.vue', () => { dispatchPopstate(null); await flushPromises(); - expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(false); - }); - - it('closes when the shared dialog requests dismissal', async () => { - const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {}); - const wrapper = mountComponent(); - - await wrapper.get('[data-testid="dialog-dismiss"]').trigger('click'); - await flushPromises(); - - expect(historyGoSpy).toHaveBeenCalledWith(-1); - dispatchPopstate(null); - await flushPromises(); - + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); }); - it('closes via the top-right X button', async () => { + it('closes via the top-right X button in edit mode', async () => { const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {}); const wrapper = mountComponent(); @@ -460,165 +528,99 @@ describe('OnboardingInternalBoot.standalone.vue', () => { expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); }); - it('shows warning result when apply succeeds with warnings', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'boot-pool', - slotCount: 1, - devices: ['sda'], - bootSizeMiB: 512, - updateBios: true, - poolMode: 'hybrid', + it('shows reboot and shutdown actions when the result is locked', async () => { + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, }; - applyInternalBootSelectionMock.mockResolvedValue({ - applySucceeded: true, - hadWarnings: true, - hadNonOptimisticFailures: true, - logs: [ - { message: 'Boot configured.', type: 'success' as const }, - { message: 'BIOS update completed with warnings', type: 'error' as const }, - ], - }); const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); - - expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(true); - expect(wrapper.text()).toContain('Setup Applied with Warnings'); - expect(wrapper.find('[data-testid="warning-icon"]').exists()).toBe(true); - }); - - it('clears onboarding storage when closing after a successful result', async () => { - vi.spyOn(window.history, 'go').mockImplementation(() => {}); - const wrapper = mountComponent(); - - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); - - expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(true); - await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); - dispatchPopstate(null); - await flushPromises(); - - expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-testid="internal-boot-standalone-close"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(true); }); - it('hides the X button when internalBootApplyAttempted is true', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: false, - poolMode: 'hybrid', + it('hides all summary actions while internal boot apply is still running', async () => { + const deferred = createDeferred(); + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, }; + applyInternalBootSelectionMock.mockReturnValueOnce(deferred.promise); const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); + await advanceToSummary(wrapper); + const confirmButton = findButtonByText(wrapper, 'Confirm & Apply'); + expect(confirmButton).toBeTruthy(); + await confirmButton!.trigger('click'); await flushPromises(); - expect(draftStore.internalBootApplyAttempted).toBe(true); expect(wrapper.find('[data-testid="internal-boot-standalone-close"]').exists()).toBe(false); - }); + expect(findButtonByText(wrapper, 'Back')).toBeFalsy(); + expect(findButtonByText(wrapper, 'Confirm & Apply')).toBeFalsy(); + expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false); - it('hides "Edit Again" button when locked after apply', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: false, - poolMode: 'hybrid', - }; - applyInternalBootSelectionMock.mockResolvedValue({ - applySucceeded: false, - hadWarnings: true, - hadNonOptimisticFailures: true, - logs: [{ message: 'Setup failed', type: 'error' }], + deferred.resolve({ + applySucceeded: true, + hadWarnings: false, + hadNonOptimisticFailures: false, + logs: [{ message: 'Internal boot pool configured.', type: 'success' }], }); - - const wrapper = mountComponent(); - - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); - - expect(draftStore.internalBootApplyAttempted).toBe(true); - expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false); - }); - - it('shows "Reboot" button instead of "Close" when locked', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: true, - poolMode: 'hybrid', - }; - - const wrapper = mountComponent(); - - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); await flushPromises(); - expect(draftStore.internalBootApplyAttempted).toBe(true); - expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false); expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(true); }); - it('calls submitInternalBootReboot directly when reboot is clicked', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: true, - poolMode: 'hybrid', + it('calls reboot and shutdown helpers from the locked result actions', async () => { + configureDraftState.value = { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: false, + poolMode: 'hybrid', + }, }; const wrapper = mountComponent(); - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); + await advanceToSummary(wrapper); + await confirmAndApply(wrapper); await wrapper.get('[data-testid="internal-boot-standalone-reboot"]').trigger('click'); await flushPromises(); - expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1); - }); - - it('shows shutdown button when locked and calls submitInternalBootShutdown directly', async () => { - reactiveDraftStore.internalBootSelection = { - poolName: 'cache', - slotCount: 1, - devices: ['DISK-A'], - bootSizeMiB: 16384, - updateBios: false, - poolMode: 'hybrid', - }; - const wrapper = mountComponent(); - - await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); - await flushPromises(); - - const shutdownButton = wrapper.find('[data-testid="internal-boot-standalone-shutdown"]'); - expect(shutdownButton.exists()).toBe(true); - - await shutdownButton.trigger('click'); + await wrapper.get('[data-testid="internal-boot-standalone-shutdown"]').trigger('click'); await flushPromises(); - expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1); - expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); - }); - - it('does not show shutdown button when not locked', () => { - const wrapper = mountComponent(); - - expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(false); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts index 7259d0f41e..bcc9ad0ffb 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { flushPromises, mount } from '@vue/test-utils'; import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation'; @@ -12,7 +13,7 @@ import { createTestI18n } from '../../utils/i18n'; type MockInternalBootSelection = { poolName: string; slotCount: number; - devices: string[]; + devices: Array<{ id: string; sizeBytes: number; deviceName: string }>; bootSizeMiB: number; updateBios: boolean; poolMode: 'dedicated' | 'hybrid'; @@ -72,6 +73,10 @@ vi.mock('@unraid/ui', () => ({ props: ['items', 'type', 'collapsible', 'class'], template: `
`, }, + Spinner: { + name: 'Spinner', + template: '
', + }, })); vi.mock('@vue/apollo-composable', () => ({ @@ -98,10 +103,6 @@ useMutationMock.mockImplementation((document: unknown) => { }; }); -vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => draftStore, -})); - const gib = (value: number) => value * 1024 * 1024 * 1024; const buildContext = ( @@ -123,7 +124,13 @@ const buildContext = ( const mountComponent = () => mount(OnboardingInternalBootStep, { props: { + initialDraft: { + bootMode: draftStore.bootMode, + skipped: draftStore.bootMode !== 'storage', + selection: draftStore.internalBootSelection, + }, onComplete: vi.fn(), + onCloseOnboarding: vi.fn(), showBack: true, }, global: { @@ -203,6 +210,78 @@ describe('OnboardingInternalBootStep', () => { contextResult.value = null; }); + it('blocks the step behind a loading gate until the internal boot query is ready', async () => { + draftStore.bootMode = 'storage'; + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false); + }); + + it('shows retry and close actions when the internal boot query fails', async () => { + draftStore.bootMode = 'storage'; + contextError.value = new Error('offline'); + const onCloseOnboarding = vi.fn(); + + const wrapper = mount(OnboardingInternalBootStep, { + props: { + initialDraft: { + bootMode: draftStore.bootMode, + skipped: false, + selection: draftStore.internalBootSelection, + }, + onComplete: vi.fn(), + onCloseOnboarding, + showBack: true, + }, + global: { + plugins: [createTestI18n()], + stubs: { + UButton: { + props: ['disabled'], + emits: ['click'], + template: '', + }, + UAlert: { + inheritAttrs: true, + props: ['title', 'description'], + template: + '
{{ title }}{{ description }}
', + }, + UCheckbox: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: + '', + }, + UInput: { + props: ['modelValue', 'type', 'disabled', 'maxlength', 'min', 'max'], + emits: ['update:modelValue'], + template: + '', + }, + USelectMenu: { + props: ['modelValue', 'items', 'disabled', 'placeholder'], + emits: ['update:modelValue'], + template: + '', + }, + }, + }, + }); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true); + + await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click'); + await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click'); + + expect(refetchContextMock).toHaveBeenCalledTimes(1); + expect(onCloseOnboarding).toHaveBeenCalledTimes(1); + }); + it('renders all available server and disk eligibility codes when storage boot is blocked', async () => { draftStore.bootMode = 'storage'; contextResult.value = buildContext({ @@ -483,6 +562,28 @@ describe('OnboardingInternalBootStep', () => { expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined(); }); + it('defaults storage boot to hybrid mode', async () => { + draftStore.bootMode = 'storage'; + contextResult.value = buildContext({ + assignableDisks: [ + { + id: 'ELIGIBLE-1', + device: '/dev/sda', + size: gib(32), + serialNum: 'ELIGIBLE-1', + interfaceType: DiskInterfaceType.SATA, + }, + ], + }); + + const wrapper = mountComponent(); + await flushPromises(); + + const vm = wrapper.vm as unknown as InternalBootVm; + expect(vm.poolMode).toBe('hybrid'); + expect(wrapper.get('input[type="text"]').element).toHaveProperty('value', 'cache'); + }); + it('allows 6 GiB devices in dedicated mode but not hybrid mode', async () => { draftStore.bootMode = 'storage'; contextResult.value = buildContext({ @@ -508,7 +609,14 @@ describe('OnboardingInternalBootStep', () => { await flushPromises(); const vm = wrapper.vm as unknown as InternalBootVm; - expect(vm.poolMode).toBe('dedicated'); + expect(vm.poolMode).toBe('hybrid'); + expect(vm.getDeviceSelectItems(0)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })]) + ); + + vm.poolMode = 'dedicated'; + await nextTick(); + expect(vm.getDeviceSelectItems(0)).toEqual( expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })]) ); @@ -516,7 +624,7 @@ describe('OnboardingInternalBootStep', () => { await flushPromises(); vm.poolMode = 'hybrid'; - await flushPromises(); + await nextTick(); expect(vm.getDeviceSelectItems(0)).not.toEqual( expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })]) diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 3bf1449340..8248c9f8ef 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -1,71 +1,57 @@ -import { ref } from 'vue'; import { flushPromises, mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StepId } from '~/components/Onboarding/stepRegistry.js'; +import type { OnboardingWizardDraft } from '~/components/Onboarding/onboardingWizardState'; +import type { StepId } from '~/components/Onboarding/stepRegistry'; +import { COMPLETE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/completeUpgradeStep.mutation'; +import { SAVE_ONBOARDING_DRAFT_MUTATION } from '~/components/Onboarding/graphql/saveOnboardingDraft.mutation'; import OnboardingModal from '~/components/Onboarding/OnboardingModal.vue'; import { createTestI18n } from '../../utils/i18n'; -type InternalBootVisibilityResult = { - value: { - bootedFromFlashWithInternalBootSetup: boolean | null; - enableBootTransfer: string | null; - } | null; -}; - const { - internalBootVisibilityResult, - internalBootVisibilityLoading, + wizardRef, + onboardingContextLoading, onboardingModalStoreState, activationCodeDataStore, onboardingStatusStore, - onboardingDraftStore, purchaseStore, serverStore, themeStore, + completeOnboardingMock, + saveOnboardingDraftMock, cleanupOnboardingStorageMock, + submitInternalBootRebootMock, + submitInternalBootShutdownMock, + stepperPropsRef, } = vi.hoisted(() => ({ - internalBootVisibilityResult: { + wizardRef: { value: { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', + currentStepId: 'OVERVIEW' as StepId, + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'], + draft: {} as OnboardingWizardDraft, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }, - } as InternalBootVisibilityResult, - internalBootVisibilityLoading: { value: false }, + }, + onboardingContextLoading: { value: false }, onboardingModalStoreState: { isVisible: { value: true }, sessionSource: { value: 'automatic' as 'automatic' | 'manual' }, - closeModal: vi.fn().mockResolvedValue(true), }, activationCodeDataStore: { - loading: { value: false }, activationRequired: { value: false }, hasActivationCode: { value: true }, - registrationState: { value: 'ENOKEYFILE' as string | null }, - partnerInfo: { - value: { - partner: { name: null, url: null }, - branding: { hasPartnerLogo: false }, - }, - }, }, onboardingStatusStore: { isVersionDrift: { value: false }, completedAtVersion: { value: null as string | null }, canDisplayOnboardingModal: { value: true }, - isPartnerBuild: { value: false }, refetchOnboarding: vi.fn().mockResolvedValue(undefined), }, - onboardingDraftStore: { - currentStepId: { value: null as StepId | null }, - internalBootApplySucceeded: { value: false }, - internalBootApplyAttempted: { value: false }, - setCurrentStep: vi.fn((stepId: StepId) => { - onboardingDraftStore.currentStepId.value = stepId; - }), - }, purchaseStore: { generateUrl: vi.fn(() => 'https://example.com/activate'), openInNewTab: true, @@ -76,7 +62,12 @@ const { themeStore: { fetchTheme: vi.fn().mockResolvedValue(undefined), }, + completeOnboardingMock: vi.fn().mockResolvedValue({}), + saveOnboardingDraftMock: vi.fn(), cleanupOnboardingStorageMock: vi.fn(), + submitInternalBootRebootMock: vi.fn(), + submitInternalBootShutdownMock: vi.fn(), + stepperPropsRef: { value: null as Record | null }, })); vi.mock('pinia', async (importOriginal) => { @@ -94,10 +85,6 @@ vi.mock('@unraid/ui', () => ({ emits: ['update:modelValue'], template: '
', }, - Spinner: { - name: 'Spinner', - template: '
', - }, })); vi.mock('@heroicons/vue/24/solid', () => ({ @@ -105,60 +92,142 @@ vi.mock('@heroicons/vue/24/solid', () => ({ XMarkIcon: { template: '' }, })); +vi.mock('~/components/Onboarding/components/OnboardingLoadingState.vue', () => ({ + default: { + props: ['title', 'description'], + template: + '
{{ title }}
{{ description }}
', + }, +})); + vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({ default: { - props: ['steps', 'activeStepIndex'], - template: '
', + props: ['steps', 'activeStepIndex', 'onStepClick'], + setup(props: Record) { + stepperPropsRef.value = props; + return { props }; + }, + template: ` +
{{ props.steps.map((step) => step.id).join(",") }}|{{ props.activeStepIndex }}
+ + `, }, })); vi.mock('~/components/Onboarding/stepRegistry', () => ({ + STEP_IDS: [ + 'OVERVIEW', + 'CONFIGURE_SETTINGS', + 'CONFIGURE_BOOT', + 'ADD_PLUGINS', + 'ACTIVATE_LICENSE', + 'SUMMARY', + 'NEXT_STEPS', + ], stepComponents: { OVERVIEW: { - props: ['onComplete', 'onBack', 'showBack'], + props: ['onComplete', 'onSkipSetup', 'saveError'], template: - '
', + '
{{ saveError }}
', }, CONFIGURE_SETTINGS: { - props: ['onComplete', 'onBack', 'showBack'], - template: - '
', + props: [ + 'initialDraft', + 'onComplete', + 'onBack', + 'onCloseOnboarding', + 'showBack', + 'isSavingStep', + 'saveError', + ], + template: ` +
+
{{ saveError }}
+ + + +
+ `, }, CONFIGURE_BOOT: { - props: ['onComplete', 'onBack', 'showBack'], - template: - '
', + props: ['initialDraft', 'onComplete', 'onBack', 'onCloseOnboarding', 'showBack', 'saveError'], + template: ` +
+
{{ saveError }}
+ + +
+ `, }, ADD_PLUGINS: { - props: ['onComplete', 'onBack', 'showBack'], - template: - '
', + props: [ + 'initialDraft', + 'onComplete', + 'onSkip', + 'onBack', + 'onCloseOnboarding', + 'showBack', + 'saveError', + ], + template: ` +
+
{{ saveError }}
+ + + +
+ `, }, ACTIVATE_LICENSE: { - props: ['onComplete', 'onBack', 'showBack'], + props: ['onComplete', 'onBack', 'showBack', 'saveError'], template: - '
', + '
{{ saveError }}
', }, SUMMARY: { - props: ['onComplete', 'onBack', 'showBack'], - template: - '
', + props: [ + 'draft', + 'internalBootState', + 'onInternalBootStateChange', + 'onComplete', + 'onBack', + 'onCloseOnboarding', + 'showBack', + 'saveError', + ], + template: ` +
+
{{ saveError }}
+ + + +
+ `, }, NEXT_STEPS: { - props: ['onComplete', 'onBack', 'showBack'], - setup(props: { onComplete: () => void; onBack?: () => void; showBack?: boolean }) { - const handleClick = () => { - cleanupOnboardingStorageMock(); - props.onComplete(); - }; - - return { - handleClick, - props, - }; - }, - template: - '
', + props: ['draft', 'internalBootState', 'onComplete', 'onBack', 'showBack'], + template: ` +
+ + + +
+ `, }, }, })); @@ -173,8 +242,8 @@ vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({ useOnboardingContextDataStore: () => ({ - internalBootVisibility: internalBootVisibilityResult, - loading: internalBootVisibilityLoading, + wizard: wizardRef, + loading: onboardingContextLoading, }), })); @@ -182,10 +251,6 @@ vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({ useOnboardingStore: () => onboardingStatusStore, })); -vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => onboardingDraftStore, -})); - vi.mock('~/store/purchase', () => ({ usePurchaseStore: () => purchaseStore, })); @@ -202,286 +267,458 @@ vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({ cleanupOnboardingStorage: cleanupOnboardingStorageMock, })); +vi.mock('~/components/Onboarding/composables/internalBoot', () => ({ + submitInternalBootReboot: submitInternalBootRebootMock, + submitInternalBootShutdown: submitInternalBootShutdownMock, +})); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useMutation: (document: unknown) => { + if (document === COMPLETE_ONBOARDING_MUTATION) { + return { + mutate: completeOnboardingMock, + }; + } + + if (document === SAVE_ONBOARDING_DRAFT_MUTATION) { + return { + mutate: saveOnboardingDraftMock, + }; + } + + return { + mutate: vi.fn(), + }; + }, + }; +}); + +const mountComponent = () => + mount(OnboardingModal, { + global: { + plugins: [createTestI18n()], + }, + }); + +const findButtonByText = (wrapper: ReturnType, text: string) => + wrapper.findAll('button').find((button) => button.text().trim().toLowerCase() === text.toLowerCase()); + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + return { promise, resolve, reject }; +}; + describe('OnboardingModal.vue', () => { beforeEach(() => { vi.clearAllMocks(); - - onboardingModalStoreState.closeModal.mockImplementation(async () => { - onboardingModalStoreState.isVisible.value = false; - onboardingModalStoreState.sessionSource.value = 'automatic'; - return true; - }); - - activationCodeDataStore.loading = ref(false); - activationCodeDataStore.activationRequired = ref(false); - activationCodeDataStore.hasActivationCode = ref(true); - activationCodeDataStore.registrationState = ref('ENOKEYFILE'); + window.history.replaceState({}, '', 'http://localhost:3000/'); onboardingModalStoreState.isVisible.value = true; onboardingModalStoreState.sessionSource.value = 'automatic'; - activationCodeDataStore.registrationState.value = 'ENOKEYFILE'; - onboardingStatusStore.isVersionDrift.value = false; - onboardingStatusStore.completedAtVersion.value = null; onboardingStatusStore.canDisplayOnboardingModal.value = true; - onboardingStatusStore.isPartnerBuild.value = false; - onboardingDraftStore.currentStepId.value = null; - onboardingDraftStore.internalBootApplySucceeded.value = false; - onboardingDraftStore.internalBootApplyAttempted.value = false; - internalBootVisibilityLoading.value = false; - internalBootVisibilityResult.value = { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', + onboardingStatusStore.refetchOnboarding.mockResolvedValue(undefined); + onboardingContextLoading.value = false; + wizardRef.value = { + currentStepId: 'OVERVIEW', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; - }); - - const mountComponent = () => - mount(OnboardingModal, { - global: { - plugins: [createTestI18n()], + completeOnboardingMock.mockResolvedValue({}); + saveOnboardingDraftMock.mockResolvedValue({ + data: { + onboarding: { + saveOnboardingDraft: true, + }, }, }); - - it.each([ - ['OVERVIEW', 'overview-step'], - ['CONFIGURE_SETTINGS', 'settings-step'], - ['CONFIGURE_BOOT', 'internal-boot-step'], - ['ADD_PLUGINS', 'plugins-step'], - ['ACTIVATE_LICENSE', 'license-step'], - ['SUMMARY', 'summary-step'], - ['NEXT_STEPS', 'next-step'], - ] as const)('resumes persisted step %s when it is available', (stepId, testId) => { - onboardingDraftStore.currentStepId.value = stepId; - - const wrapper = mountComponent(); - - expect(wrapper.find(`[data-testid="${testId}"]`).exists()).toBe(true); + submitInternalBootRebootMock.mockReset(); + submitInternalBootShutdownMock.mockReset(); }); - it('renders when backend visibility is enabled', () => { + it('renders the current step from bootstrap wizard state', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'], + draft: { + coreSettings: { + serverName: 'Tower', + }, + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; + const wrapper = mountComponent(); + await flushPromises(); expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="onboarding-steps"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="overview-step"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="onboarding-steps"]').text()).toContain( + 'OVERVIEW,CONFIGURE_SETTINGS,ADD_PLUGINS|1' + ); }); - it('does not render when backend visibility is disabled', () => { - onboardingModalStoreState.isVisible.value = false; + it('normalizes JSON bootstrap draft data before re-saving step transitions', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'], + draft: { + coreSettings: { + serverName: ' Existing Tower ', + useSsh: 'nope', + }, + plugins: { + selectedIds: ['community.applications', 42], + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: '2', + devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }, null], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + } as unknown as OnboardingWizardDraft, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); - }); - - it('does not render when modal display is blocked', () => { - onboardingStatusStore.canDisplayOnboardingModal.value = false; - - const wrapper = mountComponent(); + await wrapper.get('[data-testid="settings-step-complete"]').trigger('click'); + await flushPromises(); - expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + expect(saveOnboardingDraftMock).toHaveBeenCalledWith({ + input: { + draft: { + coreSettings: { + serverName: 'Tower', + useSsh: true, + }, + plugins: { + selectedIds: ['community.applications'], + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }, + navigation: { + currentStepId: 'ADD_PLUGINS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, + }); }); - it('shows the activation step for ENOKEYFILE1', () => { - activationCodeDataStore.registrationState.value = 'ENOKEYFILE1'; - onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE'; + it('falls forward to the nearest visible step when the saved step is no longer visible', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_BOOT', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); }); - it('hides the internal boot step when boot transfer is unavailable', () => { - internalBootVisibilityResult.value = { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'no', + it('persists step transitions with nested draft data before navigating', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; - onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.get('[data-testid="settings-step-complete"]').trigger('click'); + await flushPromises(); - expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(false); + expect(saveOnboardingDraftMock).toHaveBeenCalledTimes(1); + expect(saveOnboardingDraftMock).toHaveBeenCalledWith({ + input: { + draft: { + coreSettings: { + serverName: 'Tower', + useSsh: true, + }, + plugins: undefined, + internalBoot: undefined, + }, + navigation: { + currentStepId: 'ADD_PLUGINS', + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, + }); expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); }); - it('keeps the internal boot step visible even when the server reports prior internal boot setup', () => { - internalBootVisibilityResult.value = { - bootedFromFlashWithInternalBootSetup: true, - enableBootTransfer: 'yes', + it('ignores stepper navigation while a transition save is still in flight', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; - onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT'; - const wrapper = mountComponent(); - - expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(true); - }); - - it('keeps a resumed activation step visible while activation state is still loading', async () => { - activationCodeDataStore.loading.value = true; - activationCodeDataStore.hasActivationCode.value = false; - activationCodeDataStore.registrationState.value = null; - onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE'; + const deferred = createDeferred<{ data: { onboarding: { saveOnboardingDraft: boolean } } }>(); + saveOnboardingDraftMock.mockReturnValueOnce(deferred.promise); const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true); - expect(onboardingDraftStore.setCurrentStep).not.toHaveBeenCalledWith('SUMMARY'); - - activationCodeDataStore.loading.value = false; - activationCodeDataStore.hasActivationCode.value = true; - activationCodeDataStore.registrationState.value = 'ENOKEYFILE'; + await wrapper.get('[data-testid="settings-step-complete"]').trigger('click'); await flushPromises(); - expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true); - expect(onboardingDraftStore.currentStepId.value).toBe('ACTIVATE_LICENSE'); - }); + expect(saveOnboardingDraftMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true); - it('opens exit confirmation when close button is clicked', async () => { - const wrapper = mountComponent(); + await wrapper.get('[data-testid="onboarding-steps-click-0"]').trigger('click'); + await flushPromises(); - await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); + expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="overview-step"]').exists()).toBe(false); - expect(wrapper.text()).toContain('Exit onboarding?'); - expect(wrapper.text()).toContain('Exit setup'); + deferred.resolve({ + data: { + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); + await flushPromises(); + + expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); }); - it('shows the internal boot restart guidance when exiting after internal boot was applied', async () => { - onboardingDraftStore.internalBootApplySucceeded.value = true; + it('blocks navigation and offers a close path when a save fails', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; + saveOnboardingDraftMock.mockRejectedValueOnce(new Error('offline')); const wrapper = mountComponent(); + await flushPromises(); - await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); - - expect(wrapper.text()).toContain('Internal boot has been configured.'); - expect(wrapper.text()).toContain('Please restart manually when convenient'); - }); + await wrapper.get('[data-testid="settings-step-complete"]').trigger('click'); + await flushPromises(); - it('closes onboarding through the backend-owned close path', async () => { - const wrapper = mountComponent(); + expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="settings-step-error"]').text()).not.toBe(''); + expect( + wrapper.text().match(/We couldn't finish onboarding right now\. Please try again\./g)?.length ?? 0 + ).toBe(1); await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); await flushPromises(); - const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); + expect(wrapper.text()).toContain('Exit onboarding?'); + + const exitButton = findButtonByText(wrapper, 'Exit setup'); expect(exitButton).toBeTruthy(); await exitButton!.trigger('click'); await flushPromises(); - expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); - }); - - it('closes the modal from next steps after draft cleanup instead of persisting step 2 again', async () => { - onboardingDraftStore.currentStepId.value = 'NEXT_STEPS'; - cleanupOnboardingStorageMock.mockImplementation(() => { - onboardingDraftStore.currentStepId.value = null; - }); - - const wrapper = mountComponent(); - - await wrapper.find('[data-testid="next-step-complete"]').trigger('click'); - await flushPromises(); - - expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1); - expect(onboardingDraftStore.setCurrentStep).not.toHaveBeenCalledWith('CONFIGURE_SETTINGS'); - expect(onboardingDraftStore.currentStepId.value).toBeNull(); + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); }); - it('reloads the page when completing a manually opened wizard', async () => { - onboardingModalStoreState.sessionSource.value = 'manual'; - onboardingDraftStore.currentStepId.value = 'NEXT_STEPS'; + it('opens exit confirmation and completes onboarding before closing the UI', async () => { const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined); - const wrapper = mountComponent(); await flushPromises(); - await wrapper.get('[data-testid="next-step-complete"]').trigger('click'); - await flushPromises(); - - expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1); - expect(reloadSpy).toHaveBeenCalledTimes(1); - }); - - it('shows a loading state while exit confirmation is closing the modal', async () => { - let closeModalDeferred: - | { - promise: Promise; - resolve: (value: boolean) => void; - } - | undefined; - onboardingModalStoreState.closeModal.mockImplementation(() => { - let resolve!: (value: boolean) => void; - const promise = new Promise((innerResolve) => { - resolve = innerResolve; - }); - closeModalDeferred = { promise, resolve }; - return promise; - }); - - const wrapper = mountComponent(); - await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); await flushPromises(); - const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); + expect(wrapper.text()).toContain('Exit onboarding?'); + + const exitButton = findButtonByText(wrapper, 'Exit setup'); expect(exitButton).toBeTruthy(); await exitButton!.trigger('click'); await flushPromises(); - expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); - expect(wrapper.text()).toContain('Closing setup...'); - - if (closeModalDeferred) { - closeModalDeferred.resolve(true); - } - await flushPromises(); + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(onboardingStatusStore.refetchOnboarding).toHaveBeenCalledTimes(1); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); + expect(reloadSpy).toHaveBeenCalledTimes(1); }); - it('closes onboarding without frontend completion logic', async () => { - const wrapper = mountComponent(); + it('reuses the existing exit flow when a step requests close onboarding', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; - await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); + const wrapper = mountComponent(); await flushPromises(); - const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); - expect(exitButton).toBeTruthy(); - await exitButton!.trigger('click'); + await wrapper.get('[data-testid="settings-step-close"]').trigger('click'); await flushPromises(); - expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('Exit onboarding?'); + expect(completeOnboardingMock).not.toHaveBeenCalled(); }); - it('does not reopen upgrade onboarding just from descriptive status when backend visibility is closed', () => { - onboardingModalStoreState.isVisible.value = false; - onboardingStatusStore.isVersionDrift.value = true; - onboardingStatusStore.completedAtVersion.value = '7.2.4'; + it('hides exit controls and back navigation when internal boot is locked', async () => { + wizardRef.value = { + currentStepId: 'CONFIGURE_SETTINGS', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'SUMMARY'], + draft: {}, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false); }); - it('hides the X button when internal boot lockdown is active', () => { - onboardingDraftStore.internalBootApplyAttempted.value = true; + it('carries summary-owned internal boot state into the next save', async () => { + wizardRef.value = { + currentStepId: 'SUMMARY', + visibleStepIds: ['OVERVIEW', 'SUMMARY', 'NEXT_STEPS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false); + await wrapper.get('[data-testid="summary-step-mark-locked"]').trigger('click'); + await wrapper.get('[data-testid="summary-step-complete"]').trigger('click'); + await flushPromises(); + + expect(saveOnboardingDraftMock).toHaveBeenCalledWith({ + input: { + draft: { + coreSettings: undefined, + plugins: undefined, + internalBoot: undefined, + }, + navigation: { + currentStepId: 'NEXT_STEPS', + }, + internalBootState: { + applyAttempted: true, + applySucceeded: true, + }, + }, + }); + expect(wrapper.find('[data-testid="next-step"]').exists()).toBe(true); }); - it('passes showBack=false to step components when internal boot lockdown is active', async () => { - onboardingDraftStore.internalBootApplyAttempted.value = true; - onboardingDraftStore.currentStepId.value = 'CONFIGURE_SETTINGS'; + it('completes onboarding from the final step without saving again', async () => { + const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined); + wizardRef.value = { + currentStepId: 'NEXT_STEPS', + visibleStepIds: ['OVERVIEW', 'NEXT_STEPS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false); + await wrapper.get('[data-testid="next-step-complete"]').trigger('click'); + await flushPromises(); + + expect(saveOnboardingDraftMock).not.toHaveBeenCalled(); + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(onboardingStatusStore.refetchOnboarding).toHaveBeenCalledTimes(1); + expect(reloadSpy).toHaveBeenCalledTimes(1); }); - it('does not open exit confirmation when locked and X area is somehow triggered', async () => { - onboardingDraftStore.internalBootApplyAttempted.value = true; + it('routes reboot completion through the modal-owned lifecycle', async () => { + wizardRef.value = { + currentStepId: 'NEXT_STEPS', + visibleStepIds: ['OVERVIEW', 'NEXT_STEPS'], + draft: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }; const wrapper = mountComponent(); + await flushPromises(); - expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false); - expect(wrapper.text()).not.toContain('Exit onboarding?'); + await wrapper.get('[data-testid="next-step-reboot"]').trigger('click'); + await flushPromises(); + + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(onboardingStatusStore.refetchOnboarding).toHaveBeenCalledTimes(1); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts index 741898a891..1ab243b1e8 100644 --- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts @@ -3,27 +3,17 @@ import { flushPromises, mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { COMPLETE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/completeUpgradeStep.mutation'; import OnboardingNextStepsStep from '~/components/Onboarding/steps/OnboardingNextStepsStep.vue'; import { createTestI18n } from '../../utils/i18n'; -const { - draftStore, - activationCodeDataStore, - submitInternalBootRebootMock, - submitInternalBootShutdownMock, - cleanupOnboardingStorageMock, - completeOnboardingMock, - refetchOnboardingMock, - useMutationMock, -} = vi.hoisted(() => ({ +const { draftStore, activationCodeDataStore } = vi.hoisted(() => ({ draftStore: { internalBootApplySucceeded: false, internalBootApplyAttempted: false, internalBootSelection: null as { poolName: string; slotCount: number; - devices: string[]; + devices: Array<{ id: string; sizeBytes: number; deviceName: string }>; bootSizeMiB: number; updateBios: boolean; poolMode: 'dedicated' | 'hybrid'; @@ -44,14 +34,14 @@ const { value: null, }, }, - submitInternalBootRebootMock: vi.fn(), - submitInternalBootShutdownMock: vi.fn(), - cleanupOnboardingStorageMock: vi.fn(), - completeOnboardingMock: vi.fn().mockResolvedValue({}), - refetchOnboardingMock: vi.fn().mockResolvedValue({}), - useMutationMock: vi.fn(), })); +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + vi.mock('@unraid/ui', () => ({ BrandButton: { props: ['text', 'disabled'], @@ -61,38 +51,10 @@ vi.mock('@unraid/ui', () => ({ }, })); -vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => reactive(draftStore), -})); - vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: () => reactive(activationCodeDataStore), })); -vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({ - useOnboardingStore: () => ({ - refetchOnboarding: refetchOnboardingMock, - }), -})); - -vi.mock('~/components/Onboarding/composables/internalBoot', () => ({ - submitInternalBootReboot: submitInternalBootRebootMock, - submitInternalBootShutdown: submitInternalBootShutdownMock, -})); - -vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({ - cleanupOnboardingStorage: cleanupOnboardingStorageMock, -})); - -vi.mock('@vue/apollo-composable', async () => { - const actual = - await vi.importActual('@vue/apollo-composable'); - return { - ...actual, - useMutation: useMutationMock, - }; -}); - describe('OnboardingNextStepsStep', () => { beforeEach(() => { vi.clearAllMocks(); @@ -100,21 +62,27 @@ describe('OnboardingNextStepsStep', () => { draftStore.internalBootApplySucceeded = false; draftStore.internalBootApplyAttempted = false; draftStore.internalBootSelection = null; - completeOnboardingMock.mockResolvedValue({}); - refetchOnboardingMock.mockResolvedValue({}); - useMutationMock.mockImplementation((doc: unknown) => { - if (doc === COMPLETE_ONBOARDING_MUTATION) { - return { mutate: completeOnboardingMock }; - } - return { mutate: vi.fn() }; - }); }); - const mountComponent = () => { - const onComplete = vi.fn(); + const mountComponent = ({ + onComplete = vi.fn().mockResolvedValue(undefined), + }: { + onComplete?: ReturnType; + } = {}) => { const wrapper = mount(OnboardingNextStepsStep, { props: { - onComplete, + draft: { + internalBoot: { + bootMode: draftStore.internalBootSelection ? 'storage' : 'usb', + skipped: draftStore.internalBootSelection === null, + selection: draftStore.internalBootSelection, + }, + }, + internalBootState: { + applyAttempted: draftStore.internalBootApplyAttempted, + applySucceeded: draftStore.internalBootApplySucceeded, + }, + onComplete: onComplete as (options?: { action?: 'reboot' | 'shutdown' }) => Promise, showBack: true, }, global: { @@ -140,6 +108,18 @@ describe('OnboardingNextStepsStep', () => {
`, }, + InternalBootConfirmDialog: { + props: ['open', 'action', 'disabled'], + emits: ['confirm', 'cancel'], + template: ` +
+
{{ action === 'shutdown' ? 'Confirm Shutdown' : 'Confirm Reboot' }}
+
Please do NOT remove your Unraid flash drive
+ + +
+ `, + }, }, }, }); @@ -154,18 +134,16 @@ describe('OnboardingNextStepsStep', () => { await button.trigger('click'); await flushPromises(); - expect(completeOnboardingMock).toHaveBeenCalledTimes(1); - expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); expect(onComplete).toHaveBeenCalledTimes(1); - expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith(undefined); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); }); - it('marks onboarding complete through the same path before rebooting', async () => { + it('keeps the power action behind a confirmation dialog and delegates reboot to the shared completion path', async () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', @@ -178,8 +156,6 @@ describe('OnboardingNextStepsStep', () => { await flushPromises(); expect(wrapper.text()).toContain('Confirm Reboot'); - expect(wrapper.text()).toContain('Please do NOT remove your Unraid flash drive'); - expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); expect(onComplete).not.toHaveBeenCalled(); const confirmButton = wrapper @@ -189,47 +165,29 @@ describe('OnboardingNextStepsStep', () => { await confirmButton!.trigger('click'); await flushPromises(); - expect(completeOnboardingMock).toHaveBeenCalledTimes(1); - expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); - expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1); - expect(onComplete).not.toHaveBeenCalled(); - }); - - it('continues when the completion refresh fails after marking onboarding complete', async () => { - refetchOnboardingMock.mockRejectedValueOnce(new Error('refresh failed')); - const { wrapper, onComplete } = mountComponent(); - - const button = wrapper.find('[data-testid="brand-button"]'); - await button.trigger('click'); - await flushPromises(); - - expect(completeOnboardingMock).toHaveBeenCalledTimes(1); - expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ action: 'reboot' }); }); it('shows an error and stays on the page when completion fails', async () => { - completeOnboardingMock.mockRejectedValueOnce(new Error('offline')); - const { wrapper, onComplete } = mountComponent(); + const { wrapper, onComplete } = mountComponent({ + onComplete: vi.fn().mockRejectedValueOnce(new Error('offline')), + }); const button = wrapper.find('[data-testid="brand-button"]'); await button.trigger('click'); await flushPromises(); expect(wrapper.find('[role="alert"]').exists()).toBe(true); - expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled(); - expect(refetchOnboardingMock).not.toHaveBeenCalled(); - expect(onComplete).not.toHaveBeenCalled(); - expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith(undefined); }); it('shows reboot button when internalBootSelection is non-null but apply did not succeed', async () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', @@ -245,7 +203,7 @@ describe('OnboardingNextStepsStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', @@ -261,7 +219,7 @@ describe('OnboardingNextStepsStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', @@ -277,14 +235,15 @@ describe('OnboardingNextStepsStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', }; draftStore.internalBootApplySucceeded = true; - completeOnboardingMock.mockRejectedValueOnce(new Error('offline')); - const { wrapper, onComplete } = mountComponent(); + const { wrapper, onComplete } = mountComponent({ + onComplete: vi.fn().mockRejectedValueOnce(new Error('offline')), + }); const button = wrapper.find('[data-testid="brand-button"]'); await button.trigger('click'); @@ -297,17 +256,16 @@ describe('OnboardingNextStepsStep', () => { await confirmButton!.trigger('click'); await flushPromises(); - expect(completeOnboardingMock).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); - expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1); - expect(onComplete).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ action: 'reboot' }); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); }); it('shows shutdown button when internal boot is configured', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', @@ -327,7 +285,7 @@ describe('OnboardingNextStepsStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', @@ -351,24 +309,22 @@ describe('OnboardingNextStepsStep', () => { await confirmButton!.trigger('click'); await flushPromises(); - expect(completeOnboardingMock).toHaveBeenCalledTimes(1); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); - expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1); - expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); - expect(onComplete).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ action: 'shutdown' }); }); it('proceeds to shutdown even when completeOnboarding throws', async () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', }; - completeOnboardingMock.mockRejectedValueOnce(new Error('offline')); - const { wrapper, onComplete } = mountComponent(); + const { wrapper, onComplete } = mountComponent({ + onComplete: vi.fn().mockRejectedValueOnce(new Error('offline')), + }); const shutdownButton = wrapper .findAll('button') @@ -382,8 +338,8 @@ describe('OnboardingNextStepsStep', () => { await confirmButton!.trigger('click'); await flushPromises(); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); - expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1); - expect(onComplete).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ action: 'shutdown' }); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts index 766b23f852..c919ae798e 100644 --- a/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts @@ -6,36 +6,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import OnboardingOverviewStep from '~/components/Onboarding/steps/OnboardingOverviewStep.vue'; import { createTestI18n } from '../../utils/i18n'; -const { - completeOnboardingMock, - cleanupOnboardingStorageMock, - refetchOnboardingMock, - partnerInfoRef, - isFreshInstallRef, - isUpgradeRef, - isDowngradeRef, - isIncompleteRef, - themeRef, -} = vi.hoisted(() => ({ - completeOnboardingMock: vi.fn().mockResolvedValue({}), - cleanupOnboardingStorageMock: vi.fn(), - refetchOnboardingMock: vi.fn().mockResolvedValue({}), - partnerInfoRef: { - value: { - partner: { name: 'Partner' }, - branding: { - hasPartnerLogo: true, - partnerLogoLightUrl: 'data:image/png;base64,AAA=', - partnerLogoDarkUrl: 'data:image/png;base64,BBB=', +const { partnerInfoRef, isFreshInstallRef, isUpgradeRef, isDowngradeRef, isIncompleteRef, themeRef } = + vi.hoisted(() => ({ + partnerInfoRef: { + value: { + partner: { name: 'Partner' }, + branding: { + hasPartnerLogo: true, + partnerLogoLightUrl: 'data:image/png;base64,AAA=', + partnerLogoDarkUrl: 'data:image/png;base64,BBB=', + }, }, }, - }, - isFreshInstallRef: { value: false }, - isUpgradeRef: { value: false }, - isDowngradeRef: { value: false }, - isIncompleteRef: { value: true }, - themeRef: { value: { name: 'azure' } }, -})); + isFreshInstallRef: { value: false }, + isUpgradeRef: { value: false }, + isDowngradeRef: { value: false }, + isIncompleteRef: { value: true }, + themeRef: { value: { name: 'azure' } }, + })); vi.mock('pinia', async (importOriginal) => { const actual = await importOriginal(); @@ -45,17 +33,6 @@ vi.mock('pinia', async (importOriginal) => { }; }); -vi.mock('@vue/apollo-composable', async () => { - const actual = - await vi.importActual('@vue/apollo-composable'); - return { - ...actual, - useMutation: () => ({ - mutate: completeOnboardingMock, - }), - }; -}); - vi.mock('@unraid/ui', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { @@ -81,7 +58,6 @@ vi.mock('@/components/Onboarding/store/onboardingStatus', () => ({ isUpgrade: isUpgradeRef, isDowngrade: isDowngradeRef, isIncomplete: isIncompleteRef, - refetchOnboarding: refetchOnboardingMock, }), })); @@ -91,10 +67,6 @@ vi.mock('@/store/theme', () => ({ }), })); -vi.mock('@/components/Onboarding/store/onboardingStorageCleanup', () => ({ - cleanupOnboardingStorage: cleanupOnboardingStorageMock, -})); - describe('OnboardingOverviewStep', () => { beforeEach(() => { vi.clearAllMocks(); @@ -117,6 +89,7 @@ describe('OnboardingOverviewStep', () => { mount(OnboardingOverviewStep, { props: { onComplete: vi.fn(), + onSkipSetup: vi.fn(), }, global: { plugins: [createTestI18n()], @@ -143,20 +116,38 @@ describe('OnboardingOverviewStep', () => { expect(updatedImg.attributes('alt')).toBe('Limitless Possibilities'); }); - it('clears onboarding draft immediately when skipping setup', async () => { - const wrapper = mountComponent(); + it('delegates skip setup through the shared exit callback', async () => { + const onSkipSetup = vi.fn(); + const wrapper = mount(OnboardingOverviewStep, { + props: { + onComplete: vi.fn(), + onSkipSetup, + }, + global: { + plugins: [createTestI18n()], + }, + }); await wrapper.find('[data-testid="skip-setup-button"]').trigger('click'); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); + expect(onSkipSetup).toHaveBeenCalledTimes(1); }); - it('still clears onboarding draft when skip completion fails', async () => { - completeOnboardingMock.mockRejectedValueOnce(new Error('offline')); - const wrapper = mountComponent(); + it('does not crash when the shared exit callback rejects', async () => { + const onSkipSetup = vi.fn().mockRejectedValueOnce(new Error('offline')); + const wrapper = mount(OnboardingOverviewStep, { + props: { + onComplete: vi.fn(), + onSkipSetup, + }, + global: { + plugins: [createTestI18n()], + }, + }); await wrapper.find('[data-testid="skip-setup-button"]').trigger('click'); + await Promise.resolve(); - expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); + expect(onSkipSetup).toHaveBeenCalledTimes(1); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts index 5a911c6115..872184b87d 100644 --- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts @@ -6,11 +6,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import OnboardingPluginsStep from '~/components/Onboarding/steps/OnboardingPluginsStep.vue'; import { createTestI18n } from '../../utils/i18n'; -const { draftStore, installedPluginsLoading, installedPluginsResult, useQueryMock } = vi.hoisted(() => ({ +const { + draftStore, + installedPluginsLoading, + installedPluginsResult, + installedPluginsError, + refetchInstalledPluginsMock, + useQueryMock, +} = vi.hoisted(() => ({ draftStore: { selectedPlugins: new Set(), - pluginSelectionInitialized: false, - setPlugins: vi.fn(), }, installedPluginsLoading: { value: false, @@ -20,6 +25,10 @@ const { draftStore, installedPluginsLoading, installedPluginsResult, useQueryMoc installedUnraidPlugins: [], } as { installedUnraidPlugins: string[] } | null, }, + installedPluginsError: { + value: null as unknown, + }, + refetchInstalledPluginsMock: vi.fn().mockResolvedValue(undefined), useQueryMock: vi.fn(), })); @@ -36,10 +45,6 @@ vi.mock('@unraid/ui', () => ({ }, })); -vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => draftStore, -})); - vi.mock('@vue/apollo-composable', async () => { const actual = await vi.importActual('@vue/apollo-composable'); @@ -54,17 +59,19 @@ describe('OnboardingPluginsStep', () => { vi.clearAllMocks(); document.body.innerHTML = ''; draftStore.selectedPlugins = new Set(); - draftStore.pluginSelectionInitialized = false; installedPluginsLoading.value = false; installedPluginsResult.value = { installedUnraidPlugins: [], }; + installedPluginsError.value = null; useQueryMock.mockImplementation((query: unknown) => { if (query === INSTALLED_UNRAID_PLUGINS_QUERY) { return { result: installedPluginsResult, loading: installedPluginsLoading, + error: installedPluginsError, + refetch: refetchInstalledPluginsMock, }; } return { result: { value: null } }; @@ -76,6 +83,13 @@ describe('OnboardingPluginsStep', () => { onComplete: vi.fn(), onBack: vi.fn(), onSkip: vi.fn(), + onCloseOnboarding: vi.fn(), + initialDraft: + draftStore.selectedPlugins.size > 0 + ? { + selectedIds: Array.from(draftStore.selectedPlugins), + } + : undefined, showBack: true, showSkip: true, ...overrides, @@ -132,11 +146,10 @@ describe('OnboardingPluginsStep', () => { expect(nextButton).toBeTruthy(); await nextButton!.trigger('click'); - expect(draftStore.setPlugins).toHaveBeenCalled(); - const lastCallIndex = draftStore.setPlugins.mock.calls.length - 1; - const selected = draftStore.setPlugins.mock.calls[lastCallIndex][0] as Set; - expect(Array.from(selected)).toEqual(['community-apps']); expect(props.onComplete).toHaveBeenCalledTimes(1); + expect(props.onComplete).toHaveBeenCalledWith({ + selectedIds: ['community-apps'], + }); }); it('persists already installed plugins alongside manual selections', async () => { @@ -161,11 +174,10 @@ describe('OnboardingPluginsStep', () => { expect(nextButton).toBeTruthy(); await nextButton!.trigger('click'); - expect(draftStore.setPlugins).toHaveBeenCalled(); - const lastCallIndex = draftStore.setPlugins.mock.calls.length - 1; - const selected = draftStore.setPlugins.mock.calls[lastCallIndex][0] as Set; - expect(Array.from(selected).sort()).toEqual(['community-apps', 'fix-common-problems', 'tailscale']); expect(props.onComplete).toHaveBeenCalledTimes(1); + expect(props.onComplete).toHaveBeenCalledWith({ + selectedIds: ['community-apps', 'fix-common-problems', 'tailscale'], + }); }); it('disables the primary action until installed plugins finish loading', async () => { @@ -176,16 +188,26 @@ describe('OnboardingPluginsStep', () => { await flushPromises(); - const nextButton = wrapper - .findAll('button') - .find((button) => button.text().toLowerCase().includes('next')); + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + }); - expect(nextButton).toBeTruthy(); - expect((nextButton!.element as HTMLButtonElement).disabled).toBe(true); + it('shows retry and close actions when the installed plugins query fails', async () => { + installedPluginsError.value = new Error('offline'); + const onCloseOnboarding = vi.fn(); + + const { wrapper } = mountComponent({ onCloseOnboarding }); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true); + + await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click'); + await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click'); + + expect(refetchInstalledPluginsMock).toHaveBeenCalledTimes(1); + expect(onCloseOnboarding).toHaveBeenCalledTimes(1); }); it('skip clears selection and calls onSkip', async () => { - draftStore.pluginSelectionInitialized = true; draftStore.selectedPlugins = new Set(['community-apps']); const { wrapper, props } = mountComponent(); @@ -199,15 +221,14 @@ describe('OnboardingPluginsStep', () => { expect(skipButton).toBeTruthy(); await skipButton!.trigger('click'); - expect(draftStore.setPlugins).toHaveBeenCalledTimes(1); - const selected = draftStore.setPlugins.mock.calls[0][0] as Set; - expect(selected.size).toBe(0); expect(props.onSkip).toHaveBeenCalledTimes(1); + expect(props.onSkip).toHaveBeenCalledWith({ + selectedIds: [], + }); expect(props.onComplete).not.toHaveBeenCalled(); }); it('skip preserves detected installed plugins without keeping manual selections', async () => { - draftStore.pluginSelectionInitialized = true; draftStore.selectedPlugins = new Set(['community-apps', 'tailscale']); installedPluginsResult.value = { installedUnraidPlugins: ['fix.common.problems.plg'], @@ -224,10 +245,26 @@ describe('OnboardingPluginsStep', () => { expect(skipButton).toBeTruthy(); await skipButton!.trigger('click'); - expect(draftStore.setPlugins).toHaveBeenCalledTimes(1); - const selected = draftStore.setPlugins.mock.calls[0][0] as Set; - expect(Array.from(selected)).toEqual(['fix-common-problems']); expect(props.onSkip).toHaveBeenCalledTimes(1); + expect(props.onSkip).toHaveBeenCalledWith({ + selectedIds: ['fix-common-problems'], + }); expect(props.onComplete).not.toHaveBeenCalled(); }); + + it('preserves an explicit empty plugin selection instead of restoring defaults', async () => { + const { wrapper } = mountComponent({ + initialDraft: { + selectedIds: [], + }, + }); + + await flushPromises(); + + const switches = wrapper.findAll('[role="switch"]'); + expect(switches.length).toBe(3); + expect(switches[0].attributes('data-state')).toBe('unchecked'); + expect(switches[1].attributes('data-state')).toBe('unchecked'); + expect(switches[2].attributes('data-state')).toBe('unchecked'); + }); }); diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index 32eb5cdf83..d7ede1d2d3 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -10,6 +10,7 @@ import { } from '@/components/Onboarding/graphql/coreSettings.mutations'; import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; +import { UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION } from '@/components/Onboarding/graphql/updateServerIdentityAndResume.mutation'; import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/updateSystemTime.mutation'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -31,14 +32,22 @@ const { isFreshInstallRef, activationCodeRef, coreSettingsResult, + coreSettingsLoading, coreSettingsError, internalBootContextResult, installedPluginsResult, + installedPluginsLoading, + installedPluginsError, availableLanguagesResult, + availableLanguagesLoading, + availableLanguagesError, + refetchCoreSettingsMock, refetchInstalledPluginsMock, + refetchAvailableLanguagesMock, setModalHiddenMock, updateSystemTimeMock, updateServerIdentityMock, + updateServerIdentityAndResumeMock, setThemeMock, setLocaleMock, updateSshSettingsMock, @@ -62,7 +71,7 @@ const { internalBootSelection: null as { poolName: string; slotCount: number; - devices: string[]; + devices: Array<{ id: string; sizeBytes: number; deviceName: string }>; bootSizeMiB: number; updateBios: boolean; poolMode: 'dedicated' | 'hybrid'; @@ -84,9 +93,12 @@ const { coreSettingsResult: { value: null as unknown, }, + coreSettingsLoading: { value: false }, coreSettingsError: { value: null as unknown }, internalBootContextResult: { value: null as GetInternalBootContextQuery | null }, installedPluginsResult: { value: { installedUnraidPlugins: [] as string[] } }, + installedPluginsLoading: { value: false }, + installedPluginsError: { value: null as unknown }, availableLanguagesResult: { value: { customization: { @@ -97,10 +109,15 @@ const { }, }, }, + availableLanguagesLoading: { value: false }, + availableLanguagesError: { value: null as unknown }, + refetchCoreSettingsMock: vi.fn().mockResolvedValue(undefined), refetchInstalledPluginsMock: vi.fn().mockResolvedValue(undefined), + refetchAvailableLanguagesMock: vi.fn().mockResolvedValue(undefined), setModalHiddenMock: vi.fn(), updateSystemTimeMock: vi.fn().mockResolvedValue({}), updateServerIdentityMock: vi.fn().mockResolvedValue({}), + updateServerIdentityAndResumeMock: vi.fn().mockResolvedValue({}), setThemeMock: vi.fn().mockResolvedValue({}), setLocaleMock: vi.fn().mockResolvedValue({}), updateSshSettingsMock: vi.fn().mockResolvedValue({}), @@ -113,6 +130,22 @@ const { useQueryMock: vi.fn(), })); +const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({ + id, + sizeBytes, + deviceName, +}); + +const mockLocation = { + origin: 'https://tower.local:4443', + hostname: 'tower.local', + pathname: '/', + search: '', + hash: '', + reload: vi.fn(), + replace: vi.fn(), +}; + vi.mock('pinia', async (importOriginal) => { const actual = await importOriginal(); return { @@ -121,6 +154,8 @@ vi.mock('pinia', async (importOriginal) => { }; }); +vi.stubGlobal('location', mockLocation); + vi.mock('@unraid/ui', () => ({ BrandButton: { props: ['text', 'disabled'], @@ -132,6 +167,10 @@ vi.mock('@unraid/ui', () => ({ props: ['items', 'type', 'collapsible', 'class'], template: `
`, }, + Spinner: { + name: 'Spinner', + template: '
', + }, })); vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({ @@ -141,14 +180,6 @@ vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({ }, })); -vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => ({ - ...draftStore, - setInternalBootApplySucceeded: setInternalBootApplySucceededMock, - setInternalBootApplyAttempted: setInternalBootApplyAttemptedMock, - }), -})); - vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: () => ({ registrationState: registrationStateRef, @@ -202,6 +233,9 @@ const setupApolloMocks = () => { if (doc === UPDATE_SERVER_IDENTITY_MUTATION) { return { mutate: updateServerIdentityMock }; } + if (doc === UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION) { + return { mutate: updateServerIdentityAndResumeMock }; + } if (doc === SET_THEME_MUTATION) { return { mutate: setThemeMock }; } @@ -219,7 +253,12 @@ const setupApolloMocks = () => { useQueryMock.mockImplementation((doc: unknown) => { if (doc === GET_CORE_SETTINGS_QUERY) { - return { result: coreSettingsResult, error: coreSettingsError }; + return { + result: coreSettingsResult, + loading: coreSettingsLoading, + error: coreSettingsError, + refetch: refetchCoreSettingsMock, + }; } if (doc === GetInternalBootContextDocument) { return { result: internalBootContextResult }; @@ -227,21 +266,57 @@ const setupApolloMocks = () => { if (doc === INSTALLED_UNRAID_PLUGINS_QUERY) { return { result: installedPluginsResult, + loading: installedPluginsLoading, + error: installedPluginsError, refetch: refetchInstalledPluginsMock, }; } if (doc === GET_AVAILABLE_LANGUAGES_QUERY) { - return { result: availableLanguagesResult }; + return { + result: availableLanguagesResult, + loading: availableLanguagesLoading, + error: availableLanguagesError, + refetch: refetchAvailableLanguagesMock, + }; } return { result: { value: null } }; }); }; const mountComponent = (props: Record = {}) => { - const onComplete = vi.fn(); + const onComplete = + (props.onComplete as (() => void | Promise) | undefined) ?? + vi.fn<() => void | Promise>(); const wrapper = mount(OnboardingSummaryStep, { props: { + draft: { + coreSettings: { + serverName: draftStore.serverName, + serverDescription: draftStore.serverDescription, + timeZone: draftStore.selectedTimeZone, + theme: draftStore.selectedTheme, + language: draftStore.selectedLanguage, + useSsh: draftStore.useSsh, + }, + plugins: { + selectedIds: Array.from(draftStore.selectedPlugins), + }, + internalBoot: { + bootMode: draftStore.bootMode, + skipped: draftStore.internalBootSkipped, + selection: draftStore.internalBootSelection, + }, + }, + internalBootState: { + applyAttempted: draftStore.internalBootApplyAttempted, + applySucceeded: draftStore.internalBootApplySucceeded, + }, + onInternalBootStateChange: vi.fn((state: { applyAttempted: boolean; applySucceeded: boolean }) => { + setInternalBootApplyAttemptedMock(state.applyAttempted); + setInternalBootApplySucceededMock(state.applySucceeded); + }), onComplete, + onCloseOnboarding: vi.fn(), showBack: true, ...props, }, @@ -277,6 +352,7 @@ const mountComponent = (props: Record = {}) => { vm.showBootDriveWarningDialog ? 'Confirm Drive Wipe' : '', vm.showApplyResultDialog ? vm.applyResultTitle : '', vm.showApplyResultDialog ? vm.applyResultMessage : '', + vm.showApplyResultDialog ? (vm.applyResultFollowUpMessage ?? '') : '', ] .filter(Boolean) .join(' '); @@ -287,11 +363,41 @@ const mountComponent = (props: Record = {}) => { return { wrapper, onComplete }; }; +const buildExpectedResumeInput = (expectedServerName: string) => ({ + draft: { + coreSettings: { + serverName: draftStore.serverName, + serverDescription: draftStore.serverDescription, + timeZone: draftStore.selectedTimeZone, + theme: draftStore.selectedTheme, + language: draftStore.selectedLanguage, + useSsh: draftStore.useSsh, + }, + plugins: { + selectedIds: Array.from(draftStore.selectedPlugins), + }, + internalBoot: { + bootMode: draftStore.bootMode, + skipped: draftStore.internalBootSkipped, + selection: draftStore.internalBootSelection, + }, + }, + navigation: { + currentStepId: 'NEXT_STEPS', + }, + internalBootState: { + applyAttempted: draftStore.internalBootApplyAttempted, + applySucceeded: draftStore.internalBootApplySucceeded, + }, + expectedServerName, +}); + interface SummaryVm { showApplyResultDialog: boolean; showBootDriveWarningDialog: boolean; applyResultTitle: string; applyResultMessage: string; + applyResultFollowUpMessage: string | null; applyResultSeverity: 'success' | 'warning' | 'error'; handleBootDriveWarningConfirm: () => Promise; handleBootDriveWarningCancel: () => void; @@ -392,6 +498,14 @@ describe('OnboardingSummaryStep', () => { vi.clearAllMocks(); document.body.innerHTML = ''; setupApolloMocks(); + mockLocation.origin = 'https://tower.local:4443'; + mockLocation.hostname = 'tower.local'; + mockLocation.pathname = '/'; + mockLocation.search = ''; + mockLocation.hash = ''; + mockLocation.reload.mockReset(); + mockLocation.replace.mockReset(); + updateServerIdentityAndResumeMock.mockReset(); draftStore.serverName = 'Tower'; draftStore.serverDescription = ''; @@ -409,6 +523,7 @@ describe('OnboardingSummaryStep', () => { registrationStateRef.value = 'ENOKEYFILE'; isFreshInstallRef.value = true; activationCodeRef.value = null; + coreSettingsLoading.value = false; coreSettingsError.value = null; coreSettingsResult.value = { vars: { name: 'Tower', useSsh: false, localTld: 'local' }, @@ -444,7 +559,11 @@ describe('OnboardingSummaryStep', () => { ], }, }; + installedPluginsLoading.value = false; + installedPluginsError.value = null; installedPluginsResult.value = { installedUnraidPlugins: [] }; + availableLanguagesLoading.value = false; + availableLanguagesError.value = null; availableLanguagesResult.value = { customization: { availableLanguages: [ @@ -455,7 +574,29 @@ describe('OnboardingSummaryStep', () => { }; updateSystemTimeMock.mockResolvedValue({}); - updateServerIdentityMock.mockResolvedValue({}); + updateServerIdentityMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Tower', + comment: '', + defaultUrl: 'https://Tower.local:4443', + }, + }, + }); + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Tower', + comment: '', + defaultUrl: 'https://Tower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); setThemeMock.mockResolvedValue({}); setLocaleMock.mockResolvedValue({}); updateSshSettingsMock.mockResolvedValue({}); @@ -479,6 +620,35 @@ describe('OnboardingSummaryStep', () => { refetchInstalledPluginsMock.mockResolvedValue(undefined); }); + it('blocks summary apply behind a loading gate until all required queries are ready', async () => { + coreSettingsResult.value = null; + coreSettingsLoading.value = true; + + const { wrapper } = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="brand-button"]').exists()).toBe(false); + }); + + it('shows retry and close actions when a required summary query fails', async () => { + coreSettingsError.value = new Error('offline'); + const onCloseOnboarding = vi.fn(); + + const { wrapper } = mountComponent({ onCloseOnboarding }); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true); + + await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click'); + await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click'); + + expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(refetchInstalledPluginsMock).toHaveBeenCalledTimes(1); + expect(refetchAvailableLanguagesMock).toHaveBeenCalledTimes(1); + expect(onCloseOnboarding).toHaveBeenCalledTimes(1); + }); + it.each([ { caseName: 'skips install when plugin is already present', @@ -770,6 +940,7 @@ describe('OnboardingSummaryStep', () => { expect(updateSystemTimeMock).not.toHaveBeenCalled(); expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); expect(setThemeMock).not.toHaveBeenCalled(); expect(setLocaleMock).not.toHaveBeenCalled(); expect(updateSshSettingsMock).not.toHaveBeenCalled(); @@ -791,6 +962,7 @@ describe('OnboardingSummaryStep', () => { await clickApply(wrapper); expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); expect(updateSystemTimeMock).not.toHaveBeenCalled(); expect(setThemeMock).not.toHaveBeenCalled(); expect(setLocaleMock).not.toHaveBeenCalled(); @@ -804,7 +976,12 @@ describe('OnboardingSummaryStep', () => { draftStore.serverName = 'Tower2'; }, assertExpected: () => { - expect(updateServerIdentityMock).toHaveBeenCalledWith({ name: 'Tower2', comment: '' }); + expect(updateServerIdentityAndResumeMock).toHaveBeenCalledWith({ + name: 'Tower2', + comment: '', + sysModel: undefined, + input: buildExpectedResumeInput('Tower2'), + }); }, }, { @@ -875,6 +1052,7 @@ describe('OnboardingSummaryStep', () => { scenario.caseName !== 'server identity description only' ) { expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); } if (scenario.caseName !== 'timezone only') { expect(updateSystemTimeMock).not.toHaveBeenCalled(); @@ -891,117 +1069,40 @@ describe('OnboardingSummaryStep', () => { } }); - it('applies trusted defaults + draft values when baseline query is down', async () => { - coreSettingsResult.value = null; - coreSettingsError.value = new Error('Graphql is offline.'); - draftStore.serverName = 'MyTower'; - draftStore.serverDescription = 'Edge host'; - draftStore.selectedTimeZone = 'America/New_York'; - draftStore.selectedTheme = 'black'; - draftStore.selectedLanguage = 'en_US'; - draftStore.useSsh = true; - - const { wrapper } = mountComponent(); - await clickApply(wrapper); - - expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'America/New_York' } }); - expect(updateServerIdentityMock).toHaveBeenCalledWith({ - name: 'MyTower', - comment: 'Edge host', - }); - expect(setThemeMock).toHaveBeenCalledWith({ theme: 'black' }); - expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); - expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 }); - }); - - it('applies trusted defaults when baseline query is down and draft values are empty', async () => { + it('does not apply changes when the baseline queries fail', async () => { coreSettingsResult.value = null; coreSettingsError.value = new Error('Graphql is offline.'); - draftStore.serverName = ''; - draftStore.serverDescription = ''; - draftStore.selectedTimeZone = ''; - draftStore.selectedTheme = ''; - draftStore.selectedLanguage = ''; - draftStore.useSsh = false; const { wrapper } = mountComponent(); - await clickApply(wrapper); + await flushPromises(); - expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } }); - expect(updateServerIdentityMock).toHaveBeenCalledWith({ - name: 'Tower', - comment: '', - }); - expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' }); - expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); - expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 }); + expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true); + expect(updateSystemTimeMock).not.toHaveBeenCalled(); + expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(setThemeMock).not.toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalled(); + expect(updateSshSettingsMock).not.toHaveBeenCalled(); }); - it('keeps best-effort fallback path once readiness times out before baseline is ready', async () => { + it('stays blocked instead of falling back after waiting for missing baseline data', async () => { coreSettingsResult.value = null; - coreSettingsError.value = null; - draftStore.serverName = 'Tower'; - draftStore.serverDescription = ''; - draftStore.selectedTimeZone = 'UTC'; - draftStore.selectedTheme = 'white'; - draftStore.selectedLanguage = 'en_US'; - draftStore.useSsh = false; + coreSettingsLoading.value = true; const { wrapper } = mountComponent(); await vi.advanceTimersByTimeAsync(10000); - - coreSettingsResult.value = { - vars: { name: 'Tower', useSsh: false, localTld: 'local' }, - server: { name: 'Tower', comment: '' }, - display: { theme: 'white', locale: 'en_US' }, - systemTime: { timeZone: 'UTC' }, - info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, - }; await flushPromises(); - await clickApply(wrapper); - - expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } }); - expect(updateServerIdentityMock).toHaveBeenCalledWith({ - name: 'Tower', - comment: '', - }); - expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' }); - expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); - expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 }); - expect(wrapper.text()).toContain( - 'Baseline settings unavailable. Applying trusted defaults + draft values without diff checks.' - ); + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.text()).not.toContain('Baseline settings unavailable'); }); - it.each([ - { - caseName: 'baseline available — shows success (completion deferred to NextSteps)', - apply: () => {}, - assertExpected: (wrapper: ReturnType['wrapper']) => { - expect(completeOnboardingMock).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('No Updates Needed'); - expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode'); - }, - }, - { - caseName: 'baseline unavailable — shows best-effort (completion deferred to NextSteps)', - apply: () => { - coreSettingsResult.value = null; - coreSettingsError.value = new Error('Graphql is offline.'); - }, - assertExpected: (wrapper: ReturnType['wrapper']) => { - expect(completeOnboardingMock).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); - }, - }, - ])('follows apply-only result matrix ($caseName)', async (scenario) => { - scenario.apply(); - + it('shows the normal success result when required queries are ready', async () => { const { wrapper } = mountComponent(); await clickApply(wrapper); - scenario.assertExpected(wrapper); + expect(completeOnboardingMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('No Updates Needed'); + expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode'); }); it('keeps the success dialog open after apply instead of advancing immediately', async () => { @@ -1015,26 +1116,118 @@ describe('OnboardingSummaryStep', () => { expect(onComplete).not.toHaveBeenCalled(); }); - it('advances to next steps before reloading after a successful server rename', async () => { + it('advances to next steps before redirecting to the returned defaultUrl after a successful server rename', async () => { draftStore.serverName = 'Newtower'; - const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined); + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Newtower', + comment: '', + defaultUrl: 'https://Newtower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); + mockLocation.hostname = 'tower.local'; + mockLocation.pathname = '/Dashboard'; + mockLocation.search = '?foo=bar'; + mockLocation.hash = '#section'; const { wrapper, onComplete } = mountComponent(); await clickApply(wrapper); - expect(updateServerIdentityMock).toHaveBeenCalledWith({ + expect(updateServerIdentityAndResumeMock).toHaveBeenCalledWith({ name: 'Newtower', comment: '', sysModel: undefined, + input: buildExpectedResumeInput('Newtower'), }); expect(onComplete).not.toHaveBeenCalled(); + expect(getSummaryVm(wrapper).applyResultFollowUpMessage).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); + expect(wrapper.text()).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); await clickButtonByText(wrapper, 'OK'); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith( + 'https://newtower.local:4443/Dashboard?foo=bar#section' + ); + expect(mockLocation.reload).not.toHaveBeenCalled(); + }); + + it('does not redirect after a non-rename server identity update succeeds', async () => { + draftStore.serverDescription = 'Primary host'; + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); + await clickButtonByText(wrapper, 'OK'); + expect(onComplete).toHaveBeenCalledTimes(1); - expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(mockLocation.replace).not.toHaveBeenCalled(); + expect(mockLocation.reload).not.toHaveBeenCalled(); + }); + + it('shows a loading state while waiting to reconnect after a successful server rename', async () => { + draftStore.serverName = 'Newtower'; + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Newtower', + comment: '', + defaultUrl: 'https://Newtower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); + + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); + + const confirmPromise = getSummaryVm(wrapper).handleApplyResultConfirm(); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Refreshing your connection'); + expect(wrapper.text()).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith('https://newtower.local:4443/'); + expect(mockLocation.reload).not.toHaveBeenCalled(); + + await confirmPromise; + await flushPromises(); + + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('reloads the current page instead of redirecting when the user is on an IP-based URL', async () => { + draftStore.serverName = 'Newtower'; + mockLocation.origin = 'http://192.168.1.2'; + mockLocation.hostname = '192.168.1.2'; + mockLocation.pathname = '/Dashboard'; + mockLocation.search = '?foo=bar'; + mockLocation.hash = '#section'; + + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); + await clickButtonByText(wrapper, 'OK'); - reloadSpy.mockRestore(); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.reload).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith('http://192.168.1.2/Dashboard?foo=bar#section'); }); it('retries final identity update after transient network errors when SSH changed', async () => { @@ -1078,7 +1271,9 @@ describe('OnboardingSummaryStep', () => { it('prefers timeout result over warning classification when completion succeeds', async () => { draftStore.selectedPlugins = new Set(['community-apps']); draftStore.serverName = 'bad name!'; - updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + updateServerIdentityAndResumeMock.mockRejectedValue( + new Error('Server name contains invalid characters') + ); const timeoutError = new Error( 'Timed out waiting for install operation plugin-op to finish' ) as Error & { @@ -1094,25 +1289,11 @@ describe('OnboardingSummaryStep', () => { expect(wrapper.text()).not.toContain('Setup Applied with Warnings'); }); - it('shows result dialog in offline mode and advances only after OK', async () => { - coreSettingsResult.value = null; - coreSettingsError.value = new Error('Graphql is offline.'); - - const { wrapper, onComplete } = mountComponent(); - await clickApply(wrapper); - - expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true); - expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); - expect(onComplete).not.toHaveBeenCalled(); - - await clickButtonByText(wrapper, 'OK'); - - expect(onComplete).toHaveBeenCalledTimes(1); - }); - it('continues and classifies warnings when server identity mutation rejects invalid input', async () => { draftStore.serverName = 'bad name!'; - updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + updateServerIdentityAndResumeMock.mockRejectedValue( + new Error('Server name contains invalid characters') + ); const { wrapper } = mountComponent(); await clickApply(wrapper); @@ -1166,6 +1347,7 @@ describe('OnboardingSummaryStep', () => { expect(wrapper.text()).toContain('Boot Configuration'); expect(wrapper.text()).toContain('USB/Flash Drive'); + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true); }); it('hides boot configuration section when internal boot step was skipped', () => { @@ -1195,7 +1377,10 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'boot', slotCount: 2, - devices: ['DISK-A', 'DISK-B'], + devices: [ + createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'), + createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'), + ], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', @@ -1203,16 +1388,38 @@ describe('OnboardingSummaryStep', () => { const { wrapper } = mountComponent(); + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true); expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)'); expect(wrapper.text()).toContain('DISK-B - 268 GB (sdb)'); }); + it('shows an inline warning and blocks apply when boot configuration is incomplete', () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: '', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }; + + const { wrapper } = mountComponent(); + const buttons = wrapper.findAll('[data-testid="brand-button"]'); + const applyButton = buttons[buttons.length - 1]; + + expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="boot-configuration-summary-invalid"]').exists()).toBe(true); + expect(wrapper.text()).toContain('This boot configuration is incomplete.'); + expect(applyButton.attributes('disabled')).toBeDefined(); + }); + it('requires confirmation before applying storage boot drive changes', async () => { draftStore.bootMode = 'storage'; draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', @@ -1233,12 +1440,36 @@ describe('OnboardingSummaryStep', () => { expect(applyInternalBootSelectionMock).not.toHaveBeenCalled(); }); + it('ignores stale storage selection data when internal boot is now usb', async () => { + draftStore.bootMode = 'usb'; + draftStore.internalBootSkipped = false; + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 1, + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }; + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(applyInternalBootSelectionMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Boot Method'); + expect(wrapper.text()).toContain('USB/Flash Drive'); + expect(wrapper.text()).toContain('Setup Applied'); + }); + it('applies internal boot configuration without reboot and records success', async () => { draftStore.bootMode = 'storage'; draftStore.internalBootSelection = { poolName: 'cache', slotCount: 2, - devices: ['DISK-A', 'DISK-B'], + devices: [ + createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'), + createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'), + ], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', @@ -1292,7 +1523,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: false, poolMode: 'hybrid', @@ -1323,7 +1554,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['DISK-A'], + devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')], bootSizeMiB: 16384, updateBios: true, poolMode: 'hybrid', diff --git a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts index 9c564ad302..c40e7de3ec 100644 --- a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts +++ b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts @@ -1,9 +1,6 @@ -import { createPinia, setActivePinia } from 'pinia'; - import { beforeEach, describe, expect, it } from 'vitest'; import { ONBOARDING_MODAL_HIDDEN_STORAGE_KEY } from '~/components/Onboarding/constants'; -import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft'; import { cleanupOnboardingStorage, clearLegacyOnboardingModalHiddenSessionState, @@ -12,7 +9,6 @@ import { describe('onboardingStorageCleanup', () => { beforeEach(() => { - setActivePinia(createPinia()); window.localStorage.clear(); window.sessionStorage.clear(); }); @@ -29,23 +25,14 @@ describe('onboardingStorageCleanup', () => { expect(window.localStorage.getItem('unrelatedKey')).toBe('keep'); }); - it('resets the live onboarding draft store when clearing storage', () => { - const draftStore = useOnboardingDraftStore(); - draftStore.setCoreSettings({ - serverName: 'tower', - serverDescription: 'test', - timeZone: 'UTC', - theme: 'black', - language: 'en_US', - useSsh: true, - }); - draftStore.setCurrentStep('CONFIGURE_BOOT'); + it('removes every onboarding draft storage variant without touching session state', () => { + window.localStorage.setItem('pinia-onboardingDraft:legacy', '{"currentStepId":"SUMMARY"}'); + window.sessionStorage.setItem(ONBOARDING_MODAL_HIDDEN_STORAGE_KEY, 'true'); clearOnboardingDraftStorage(); - expect(draftStore.hasResumableDraft).toBe(false); - expect(draftStore.currentStepId).toBeNull(); - expect(draftStore.coreSettingsInitialized).toBe(false); + expect(window.localStorage.getItem('pinia-onboardingDraft:legacy')).toBeNull(); + expect(window.sessionStorage.getItem(ONBOARDING_MODAL_HIDDEN_STORAGE_KEY)).toBe('true'); }); it('clears legacy hidden onboarding key from sessionStorage', () => { diff --git a/web/__test__/components/Onboarding/onboardingWizardState.test.ts b/web/__test__/components/Onboarding/onboardingWizardState.test.ts new file mode 100644 index 0000000000..bdb5ecfd83 --- /dev/null +++ b/web/__test__/components/Onboarding/onboardingWizardState.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; + +import { + cloneOnboardingWizardDraft, + normalizeOnboardingWizardDraft, +} from '~/components/Onboarding/onboardingWizardState'; + +describe('normalizeOnboardingWizardDraft', () => { + it('returns a safe empty draft for non-object values', () => { + expect(normalizeOnboardingWizardDraft(null)).toEqual({}); + expect(normalizeOnboardingWizardDraft('bad input')).toEqual({}); + }); + + it('normalizes valid fields and drops malformed nested values', () => { + expect( + normalizeOnboardingWizardDraft({ + activationStepIncluded: true, + coreSettings: { + serverName: 'Tower', + useSsh: 'yes', + }, + plugins: { + selectedIds: ['community.applications', 42], + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: '2', + devices: [ + { id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }, + { id: '', sizeBytes: 0, deviceName: '' }, + ], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }) + ).toEqual({ + activationStepIncluded: true, + coreSettings: { + serverName: 'Tower', + serverDescription: undefined, + timeZone: undefined, + theme: undefined, + language: undefined, + useSsh: undefined, + }, + plugins: { + selectedIds: ['community.applications'], + }, + internalBoot: { + bootMode: 'storage', + skipped: false, + selection: { + poolName: 'cache', + slotCount: 2, + devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }], + bootSizeMiB: 16384, + updateBios: true, + poolMode: 'hybrid', + }, + }, + }); + }); + + it('preserves omitted optional arrays and malformed selection input as undefined', () => { + expect( + normalizeOnboardingWizardDraft({ + plugins: {}, + internalBoot: { + bootMode: 'storage', + selection: 'bad-selection', + }, + }) + ).toEqual({ + coreSettings: undefined, + plugins: { + selectedIds: undefined, + }, + internalBoot: { + bootMode: 'storage', + skipped: undefined, + selection: undefined, + }, + }); + }); +}); + +describe('cloneOnboardingWizardDraft', () => { + it('preserves undefined optional arrays instead of manufacturing empty ones', () => { + expect( + cloneOnboardingWizardDraft({ + activationStepIncluded: true, + plugins: { + selectedIds: undefined, + }, + internalBoot: { + bootMode: 'storage', + selection: { + devices: undefined, + }, + }, + }) + ).toEqual({ + activationStepIncluded: true, + coreSettings: undefined, + plugins: { + selectedIds: undefined, + }, + internalBoot: { + bootMode: 'storage', + skipped: undefined, + selection: { + poolName: undefined, + slotCount: undefined, + devices: undefined, + bootSizeMiB: undefined, + updateBios: undefined, + poolMode: undefined, + }, + }, + }); + }); + + it('only keeps valid integer slot counts and allows zero boot size', () => { + expect( + normalizeOnboardingWizardDraft({ + internalBoot: { + selection: { + slotCount: 1.5, + bootSizeMiB: 0, + }, + }, + }) + ).toEqual({ + coreSettings: undefined, + plugins: undefined, + internalBoot: { + bootMode: undefined, + skipped: undefined, + selection: { + poolName: undefined, + slotCount: undefined, + devices: undefined, + bootSizeMiB: 0, + updateBios: undefined, + poolMode: undefined, + }, + }, + }); + + expect( + normalizeOnboardingWizardDraft({ + internalBoot: { + selection: { + slotCount: '2', + bootSizeMiB: -1, + }, + }, + }) + ).toEqual({ + coreSettings: undefined, + plugins: undefined, + internalBoot: { + bootMode: undefined, + skipped: undefined, + selection: { + poolName: undefined, + slotCount: 2, + devices: undefined, + bootSizeMiB: undefined, + updateBios: undefined, + poolMode: undefined, + }, + }, + }); + }); +}); diff --git a/web/__test__/store/onboardingContextData.test.ts b/web/__test__/store/onboardingContextData.test.ts index c9b606e6e6..297445c1ac 100644 --- a/web/__test__/store/onboardingContextData.test.ts +++ b/web/__test__/store/onboardingContextData.test.ts @@ -54,12 +54,21 @@ describe('OnboardingContextData Store', () => { onboarding: { status: 'INCOMPLETE', onboardingState: { isFreshInstall: true }, + wizard: { + currentStepId: 'OVERVIEW', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS'], + draft: { + coreSettings: { + serverName: 'Tower', + }, + }, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, + }, }, }, - vars: { - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', - }, }, false ) @@ -75,9 +84,9 @@ describe('OnboardingContextData Store', () => { expect(store.activationCode).toEqual({ code: 'ABC-123' }); expect(store.onboarding).toMatchObject({ status: 'INCOMPLETE' }); expect(store.onboardingState).toMatchObject({ isFreshInstall: true }); - expect(store.internalBootVisibility).toEqual({ - bootedFromFlashWithInternalBootSetup: false, - enableBootTransfer: 'yes', + expect(store.wizard).toMatchObject({ + currentStepId: 'OVERVIEW', + visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS'], }); expect(store.loading).toBe(false); }); @@ -93,6 +102,6 @@ describe('OnboardingContextData Store', () => { expect(store.error).toBe(queryError); expect(store.onboarding).toBeNull(); expect(store.activationCode).toBeNull(); - expect(store.internalBootVisibility).toBeNull(); + expect(store.wizard).toBeNull(); }); }); diff --git a/web/__test__/store/onboardingModalVisibility.test.ts b/web/__test__/store/onboardingModalVisibility.test.ts index 052a040f3d..d659fdaf91 100644 --- a/web/__test__/store/onboardingModalVisibility.test.ts +++ b/web/__test__/store/onboardingModalVisibility.test.ts @@ -8,7 +8,6 @@ import type { OperationVariables } from '@apollo/client/core'; import type { UseMutationReturn } from '@vue/apollo-composable'; import type { App } from 'vue'; -import { CLOSE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/closeOnboarding.mutation'; import { OPEN_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/openOnboarding.mutation'; import { RESUME_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/resumeOnboarding.mutation'; import { useOnboardingModalStore } from '~/components/Onboarding/store/onboardingModalVisibility'; @@ -36,7 +35,6 @@ describe('OnboardingModalVisibility Store', () => { let mountTarget: HTMLElement | null = null; const openMutationMock = vi.fn(); - const closeMutationMock = vi.fn(); const resumeMutationMock = vi.fn(); const refetchOnboardingMock = vi.fn(); @@ -89,14 +87,6 @@ describe('OnboardingModalVisibility Store', () => { ); } - if (document === CLOSE_ONBOARDING_MUTATION) { - return createMutationReturn( - closeMutationMock.mockImplementation(async () => { - mockShouldOpen.value = false; - }) - ); - } - if (document === RESUME_ONBOARDING_MUTATION) { return createMutationReturn( resumeMutationMock.mockImplementation(async () => { @@ -150,16 +140,6 @@ describe('OnboardingModalVisibility Store', () => { expect(store.isVisible).toBe(true); }); - it('closes onboarding through the backend mutation', async () => { - mockShouldOpen.value = true; - - await expect(store.closeModal()).resolves.toBe(true); - - expect(closeMutationMock).toHaveBeenCalledTimes(1); - expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); - expect(store.isVisible).toBe(false); - }); - it('does not force-open when modal display is unavailable', async () => { mockCanDisplayOnboardingModal.value = false; diff --git a/web/components.d.ts b/web/components.d.ts index 8ba941e38e..58d61c2759 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -97,6 +97,7 @@ declare module 'vue' { OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default'] OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default'] 'OnboardingAdminPanel.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingAdminPanel.standalone.vue')['default'] + OnboardingBootConfigurationSummary: typeof import('./src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue')['default'] OnboardingConsole: typeof import('./src/components/Onboarding/components/OnboardingConsole.vue')['default'] OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default'] 'OnboardingInternalBoot.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue')['default'] @@ -109,6 +110,7 @@ declare module 'vue' { OnboardingPartnerLogo: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogo.vue')['default'] OnboardingPartnerLogoImg: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogoImg.vue')['default'] OnboardingPluginsStep: typeof import('./src/components/Onboarding/steps/OnboardingPluginsStep.vue')['default'] + OnboardingStepBlockingState: typeof import('./src/components/Onboarding/components/OnboardingStepBlockingState.vue')['default'] OnboardingSteps: typeof import('./src/components/Onboarding/OnboardingSteps.vue')['default'] OnboardingSummaryStep: typeof import('./src/components/Onboarding/steps/OnboardingSummaryStep.vue')['default'] Overview: typeof import('./src/components/Docker/Overview.vue')['default'] diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md new file mode 100644 index 0000000000..8ac7528ecf --- /dev/null +++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md @@ -0,0 +1,358 @@ +# Onboarding Wizard + +## Overview + +The onboarding wizard is now server-owned. + +- Durable progress lives in `onboarding-tracker.json`. +- The API decides which step IDs are visible for the current server. +- The web app keeps only in-memory draft state while the user is editing a step. +- Nothing in onboarding uses `localStorage` for draft persistence anymore. + +## Data Flow + +### Bootstrap + +`web/src/components/Onboarding/store/onboardingContextData.ts` loads `ONBOARDING_BOOTSTRAP_QUERY`. + +That query returns: + +- `customization.onboarding.status` +- `customization.onboarding.shouldOpen` +- `customization.onboarding.onboardingState` +- `customization.onboarding.wizard` + +How those fields are determined: + +- `status` + - computed server-side from tracker completion state plus version direction + - `INCOMPLETE` when `completed=false` + - `UPGRADE` / `DOWNGRADE` when onboarding was completed on a different supported version + - `COMPLETED` when onboarding was already completed for the current effective version line +- `shouldOpen` + - computed server-side from `forceOpen`, current version support, completion state, and the in-memory bypass flag + - current formula is effectively `!isBypassed && (forceOpen || (isVersionSupported && !completed))` +- `onboardingState` + - derived from current server facts, not the tracker file + - includes registration state, whether the server is already registered, whether this is a fresh install, whether an activation code exists, and whether activation is still required +- `wizard` + - built from durable tracker state plus live server state + - `visibleStepIds` are computed on read + - `currentStepId` comes from persisted navigation state, then falls to the nearest visible step if the saved step is no longer valid + - `draft` and `internalBootState` come from `onboarding-tracker.json` + +`wizard` contains: + +- `currentStepId` +- `visibleStepIds` +- `draft` +- `internalBootState` + +### Server State + +`api/src/unraid-api/config/onboarding-tracker.json` stores the durable wizard state. + +The draft is intentionally sparse: + +- a fresh/cleared tracker uses `draft: {}` +- `coreSettings`, `plugins`, and `internalBoot` appear only after that slice is actually written +- omitted sections are meaningful and should not be treated as preinitialized empty objects + +Empty/sparse example: + +```json +{ + "completed": false, + "completedAtVersion": null, + "forceOpen": false, + "draft": {}, + "navigation": { + "currentStepId": "CONFIGURE_SETTINGS" + }, + "internalBootState": { + "applyAttempted": false, + "applySucceeded": false + } +} +``` + +Example with saved draft data: + +```json +{ + "completed": false, + "completedAtVersion": null, + "forceOpen": false, + "draft": { + "coreSettings": { + "serverName": "Tower", + "timeZone": "America/New_York", + "theme": "white" + }, + "plugins": { + "selectedIds": [] + }, + "internalBoot": { + "bootMode": "storage", + "skipped": false, + "selection": { + "poolName": "cache", + "slotCount": 1, + "devices": [ + { + "id": "DISK-A", + "sizeBytes": 512110190592, + "deviceName": "sda" + } + ], + "bootSizeMiB": 16384, + "updateBios": true, + "poolMode": "hybrid" + } + } + }, + "navigation": { + "currentStepId": "SUMMARY" + }, + "internalBootState": { + "applyAttempted": false, + "applySucceeded": false + } +} +``` + +The tracker shape is defined in `api/src/unraid-api/config/onboarding-tracker.model.ts`. + +### Client State + +`web/src/components/Onboarding/OnboardingModal.vue` owns the transient in-memory wizard state. + +- It hydrates `localDraft`, `localCurrentStepId`, and `localInternalBootState` from the bootstrap query. +- Step components receive draft slices through props. +- Step components return committed values through callbacks like `onComplete`, `onBack`, and `onSkip`. + +The old Pinia draft store was removed: + +- `web/src/components/Onboarding/store/onboardingDraft.ts` + +## Core Settings Precedence + +The Core Settings step uses two different sources: + +- modal bootstrap state from `ONBOARDING_BOOTSTRAP_QUERY` +- live server values from `GET_CORE_SETTINGS_QUERY` + +They do different jobs: + +- bootstrap decides whether onboarding opens, which steps are visible, and what saved server-owned draft already exists +- the Core Settings query provides the live baseline for server identity, timezone, display settings, SSH, hostname/TLD, and current IP + +### When The Values Load + +1. `ONBOARDING_BOOTSTRAP_QUERY` loads first through `web/src/components/Onboarding/store/onboardingContextData.ts` +2. `OnboardingModal.vue` hydrates the in-memory wizard draft from `wizard.draft` +3. when the active step is `CONFIGURE_SETTINGS`, `OnboardingCoreSettingsStep.vue` runs `GET_CORE_SETTINGS_QUERY` +4. once both the live query and onboarding status are known, the step applies its final precedence rules + +That means: + +- bootstrap is enough to decide whether the wizard should show +- bootstrap is not enough to decide the final Core Settings defaults +- the step still waits for its own live query before it can fully choose between draft values, browser timezone, activation metadata, and current server settings + +### Source Priority By Field + +#### Server Name + +Priority: + +- saved draft `coreSettings.serverName` +- activation metadata on fresh setup when available +- current API/server name +- trusted fallback default + +Notes: + +- the step waits for onboarding status before deciding whether this is a fresh setup +- on fresh setup, activation metadata wins over the current API/server name +- after onboarding is already completed, the current API/server name wins over activation metadata + +#### Server Description + +Priority: + +- saved draft `coreSettings.serverDescription` +- on fresh setup with activation metadata, activation comment if one was provided +- current API/server comment +- activation comment as fallback +- trusted fallback default + +Notes: + +- on first setup with activation metadata, the description intentionally stays empty unless the activation payload included one +- after onboarding is already completed, the live server comment wins + +#### Time Zone + +Priority: + +- saved draft `coreSettings.timeZone` +- for fresh setup with no draft timezone: browser timezone +- live server timezone +- trusted fallback default + +Why onboarding status matters: + +- timezone precedence is different for fresh setup vs non-fresh setup +- fresh setup prefers browser timezone if there is no draft timezone +- non-fresh setup prefers the server timezone + +Notes: + +- the browser timezone is detected client-side +- if the browser timezone cannot be resolved, the step falls back to the live server timezone +- if neither is available, it falls back to `UTC` + +#### Theme + +Priority: + +- saved draft `coreSettings.theme` +- live display theme from `GET_CORE_SETTINGS_QUERY` +- trusted fallback default + +#### Language + +Priority: + +- saved draft `coreSettings.language` +- live display locale from `GET_CORE_SETTINGS_QUERY` +- trusted fallback default + +#### SSH Access + +Priority: + +- saved draft `coreSettings.useSsh` +- live `vars.useSsh` from `GET_CORE_SETTINGS_QUERY` +- trusted fallback default + +### Supporting Live Values + +The Core Settings query also sets a few display-only values that are not part of the draft precedence rules above: + +- `vars.localTld` feeds the hostname preview +- `info.primaryNetwork.ipAddress` feeds the current IP display + +## Saving Rules + +The wizard saves only on step transitions. + +Current transitions that persist: + +- `Continue` +- `Back` +- `Skip` + +Typing inside a step stays local until one of those actions happens. + +`Back` persists the draft snapshot from the step the user is leaving, then stores the previous visible step as `navigation.currentStepId`. It is not a no-op navigation button. + +Persistence goes through: + +- `web/src/components/Onboarding/graphql/saveOnboardingDraft.mutation.ts` +- `api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts` +- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts` + +The API mutation is intentionally minimal and returns `Boolean`. + +## Visible Steps + +The frontend still owns presentation: + +- step component registry +- step titles/descriptions/icons +- per-step UI implementation + +The API owns visibility: + +- `OnboardingService.getVisibleWizardStepIds()` + +Current server-driven rules: + +- `CONFIGURE_BOOT` appears only when internal boot transfer is available. +- `ACTIVATE_LICENSE` appears when activation is still required, or when `draft.activationStepIncluded` was set earlier in the same onboarding session. + +The visible step list is computed on read and is not persisted in the tracker. + +If the saved `currentStepId` is no longer visible, the API falls to the nearest valid visible step by using a fixed step order, not by doing math on a numeric index. + +Example: + +- saved `currentStepId = CONFIGURE_BOOT` +- current visible steps = `OVERVIEW`, `CONFIGURE_SETTINGS`, `ADD_PLUGINS`, `SUMMARY`, `NEXT_STEPS` +- `CONFIGURE_BOOT` is no longer visible +- the API walks forward in the canonical order first and returns `ADD_PLUGINS` + +If there is no later visible step, it walks backward. If neither search finds a match, it falls to the first visible step. + +## Failure Handling + +Step-transition saves are blocking. + +- Navigation does not advance until `saveOnboardingDraft` succeeds. +- The active step shows a loading state while the save is in flight. +- A failed save surfaces a retryable error in the modal. +- The user can exit with `Close onboarding`. + +`Close onboarding` currently reuses the existing close flow and logs the close reason through GraphQL when it came from a save failure. + +## Legacy Cleanup + +The only backward-compatibility behavior left is cleanup of old browser draft keys. + +`web/src/components/Onboarding/store/onboardingStorageCleanup.ts` removes: + +- `onboardingDraft` +- any related legacy key containing `onboardingDraft` +- the old session-hidden modal flag + +This runs best-effort and exists only to delete stale browser state. The cleanup does not migrate old values into the new server draft, and the new flow never reads onboarding progress from browser storage again. + +Concretely, cleanup works by calling `window.localStorage.removeItem('onboardingDraft')`, then scanning every localStorage key and removing any key whose name contains `onboardingDraft` (for example old persisted Pinia keys). It also removes the old session-hidden modal key from `sessionStorage`. + +## Adding Or Changing Steps + +### Add a New Durable Draft Section + +1. Update the tracker model in `api/src/unraid-api/config/onboarding-tracker.model.ts`. +2. Update GraphQL inputs/types in: + - `api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts` + - `api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts` +3. Normalize and persist the new fields in `OnboardingTrackerService`. +4. Map GraphQL input to tracker shape in `OnboardingService.saveOnboardingDraft()`. + +### Add a New Step + +1. Create or update the Vue step component under `web/src/components/Onboarding/steps`. +2. Register presentation metadata in `web/src/components/Onboarding/stepRegistry.ts`. +3. Add the step ID to the shared step types/enums on both API and web. +4. Update the server-side visibility rules in `OnboardingService`. +5. Wire the step into `OnboardingModal.vue`. + +## Testing + +Primary coverage lives in: + +- `api/src/unraid-api/config/onboarding-tracker.service.spec.ts` +- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts` +- `api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts` +- `web/__test__/components/Onboarding` +- `web/__test__/store/onboardingContextData.test.ts` + +Recommended manual checks: + +1. Open onboarding on an incomplete server and confirm the modal hydrates from the server. +2. Move between steps and refresh after a transition to confirm resume uses the saved server step. +3. Force a save failure and confirm navigation blocks, the error is visible, and `Close onboarding` exits. +4. Verify that no `onboardingDraft` key is recreated in `localStorage`. diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 729ea6bd6e..3387499202 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -1,25 +1,42 @@ -``` - -### 2. Register The Component And Step Metadata - -- Add the component mapping in `web/src/components/Onboarding/stepRegistry.ts` (`stepComponents`) -- Add stepper metadata in `stepRegistry.ts` (`stepMetadata`) so the timeline has title/description/icon. - -### 3. Add Step ID To The Modal And Stepper Types - -- Update `StepId` unions in: - - `web/src/components/Onboarding/OnboardingModal.vue` - - `web/src/components/Onboarding/OnboardingSteps.vue` - -### 4. Add The Step To The Client-Side Flow - -- Update `HARDCODED_STEPS` in `web/src/components/Onboarding/OnboardingModal.vue`. -- Choose whether it is required via the step's `required` flag. -- If conditional, update the `availableSteps` / `filteredSteps` computed logic. - -### 5. Wire Per-Step Props In `currentStepProps` - -Add a `switch` case in `OnboardingModal.vue` so the step receives the right callbacks/flags (`onComplete`, `onBack`, `onSkip`, `showSkip`, etc.). - -### 6. Add/Update Tests - -At minimum, update: -- `web/__test__/components/Onboarding/OnboardingModal.test.ts` -- Any step-specific test file if the new step introduces custom behavior. - -## Integration - -`OnboardingModal.vue` is already integrated and handles both onboarding modes. No additional app wiring is usually required once the step is added to `stepRegistry` + `HARDCODED_STEPS`. - -## Testing - -To test upgrade/downgrade visibility: - -1. Edit `PATHS_CONFIG_MODULES/onboarding-tracker.json` (default: `/boot/config/plugins/dynamix.my.servers/configs/onboarding-tracker.json`) -2. For upgrade flow, set: - - `completed: true` - - `completedAtVersion` to an older version than current OS version -3. For incomplete flow, set: - - `completed: false` - - `completedAtVersion: null` (or omit) -4. Restart the API -5. Reload web UI and verify `customization.onboarding.status` and modal visibility - -For rapid local testing, the onboarding admin panel (`OnboardingAdminPanel.standalone.vue`) can also apply temporary GraphQL override state. - -To test activation-step gating: - -1. Ensure activation code exists (or remove it to test conditional logic) -2. Verify `registrationState` is one of `ENOKEYFILE`, `ENOKEYFILE1`, `ENOKEYFILE2` for the license step to appear -3. Restart the API -4. Reload web UI and verify whether `ACTIVATE_LICENSE` is present in the step list - -## Notes - -- Automatic onboarding visibility is driven by backend `shouldOpen`, which now opens incomplete onboarding on supported versions -- Patch-only releases still remain silent because upgrade detection ignores same-minor version drift -- The modal automatically switches between fresh-install and version-drift copy -- There is no server-side per-step completion tracking in this flow -- Exiting onboarding can call `completeOnboarding`; temporary bypass does not -- Summary applies and validates draft settings only; final completion happens from Next Steps -- Version comparison uses semver for reliable ordering -- The same modal component handles all modes for consistency -- During apply in the summary step, if baseline core-settings query data is unavailable, onboarding runs in best-effort mode using trusted defaults plus draft values and still proceeds. This behavior is intentional to avoid hard-blocking onboarding when baseline reads are unavailable. -- Core-settings timezone precedence is mode-aware: for initial setup (`onboarding.completed=false`), the step prefers non-empty draft timezone, then browser timezone, then API baseline; for completed onboarding (upgrade/downgrade paths), API baseline timezone remains authoritative. -- Temporary onboarding bypass is available for support/partner workflows without marking onboarding complete (`Ctrl/Cmd + Alt + Shift + O`, `?onboarding=bypass`, `?onboarding=resume`). It is session-scoped and boot-aware. -- Bypass persistence intentionally uses `sessionStorage + boot marker` (not `localStorage`) so it can survive in-session navigation but still expire after reboot. - -## Detailed Init Flow And Regression Matrix - -### Purpose - -This section is the reference for: - -1. What happens during onboarding initialization (API startup and summary-step readiness). -2. Which regression cases are intentionally locked in tests. -3. How to map behaviors to exact test files. - -### Source Of Truth Files - -- Web summary behavior: `web/src/components/Onboarding/steps/OnboardingSummaryStep.vue` -- Web core settings behavior: `web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue` -- Web installer behavior: `web/src/components/Onboarding/composables/usePluginInstaller.ts` -- API startup/init behavior: `api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts` -- API activation file helpers: `api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts` - -### API Init Flow (`OnboardingService.onModuleInit`) - -#### High-Level Sequence - -1. Read paths from store (`activationBase`, user dynamix config, ident config). -2. Resolve activation directory (`/activation` vs legacy `/activate` fallback). -3. Check first-boot completion via tracker. -4. Load and validate `.activationcode` (JSON -> DTO -> class-validator). -5. Apply customizations in order: - - Partner banner - - Display settings - - Case model - -Notes: -- Init no longer applies server identity by default. Server identity is applied in onboarding summary confirmation. -- `onModuleInit()` does not call external internet APIs directly. It performs local disk operations and best-effort customization materialization. - -#### Call Matrix During Init - -| Step | Call Type | Target | Why | -| --- | --- | --- | --- | -| Resolve activation dir | filesystem access | candidate dirs from `getActivationDirCandidates()` | Detect active activation directory. | -| Locate activation file | filesystem read dir | `*.activationcode` under activation dir | Find partner activation payload. | -| Parse and validate | JSON parse + DTO validation | `ActivationCode` model | Enforce sanitized/validated inputs before mutation. | -| Banner customization | filesystem copy/symlink | activation banner -> webgui banner path | Apply partner banner if present. | -| Display customization | cfg file write | dynamix display section | Apply branding colors/banner flags. | -| Case model customization | filesystem write/symlink | case-model asset + cfg | Set partner case model when asset exists. | - -#### API Init Decision Table - -| Case | Activation dir available | Tracker completed | Activation code valid | Expected behavior | -| --- | --- | --- | --- | --- | -| I1 | No | N/A | N/A | Skip setup. | -| I2 | Yes | Yes | Any | Skip customization as already completed. | -| I3 | Yes | No | No/invalid | No activation customizations applied. | -| I4 | Yes | No | Yes | Apply banner/display/case-model in sequence. | -| I5 | Yes | First init: No, second init: Yes | Yes | First init applies; subsequent init is idempotent skip. | - -### Web Summary-Step Init And Apply Flow - -#### Readiness/Initialization - -On mount, summary step starts: - -1. `GetCoreSettings` query (baseline). -2. `InstalledUnraidPlugins` query. -3. `GetAvailableLanguages` query. -4. A 10s readiness timer; if baseline is still unavailable, apply can continue in best-effort mode. - -#### Apply Sequence (When User Clicks Confirm) - -1. Compute baseline vs target settings. -2. Apply core settings (timezone, identity, theme). -3. Apply locale (with language install when needed). -4. Install selected plugins not already installed. -5. Apply SSH settings (optimistic verification). -6. Show final result dialog based on apply/result precedence. -7. Advance to Next Steps after user confirmation. - -#### Endpoint/Operation Mapping - -| Purpose | Operation | -| --- | --- | -| Baseline settings | `GetCoreSettings` | -| Installed plugin names | `InstalledUnraidPlugins` | -| Language metadata | `GetAvailableLanguages` | -| Update timezone | `UpdateSystemTime` | -| Update identity | `UpdateServerIdentity` | -| Update theme | `setTheme` | -| Update locale | `SetLocale` | -| Update SSH | `UpdateSshSettings` | -| Install plugin | `InstallPlugin` (+ operation tracking query/subscription) | -| Install language pack | `InstallLanguage` (+ operation tracking query/subscription) | -| Final onboarding completion | `CompleteOnboarding` (triggered from Next Steps) | - -### Temporary Bypass Controls - -Store: `web/src/components/Onboarding/store/onboardingModalVisibility.ts` -Modal gate: `web/src/components/Onboarding/OnboardingModal.vue` - -Bypass is intentionally separate from onboarding completion state: - -1. Temporary bypass does not call `CompleteOnboarding`. -2. Normal exit behavior still follows existing completion flow. -3. Bypass suppresses automatic onboarding display; force-open remains user-initiated. - -Supported controls: - -- Keyboard shortcut: `Ctrl/Cmd + Alt + Shift + O` -- URL query param: `?onboarding=bypass` -- URL query param to resume: `?onboarding=resume` -- URL query param to force open: `?onboarding=open` -- Window event to force open: `window.dispatchEvent(new Event('unraid:onboarding:open'))` - -Persistence model: - -- Stored per browser session (`sessionStorage`) under `onboardingTemporaryBypass`. -- Boot-aware: bypass stores a boot marker derived from server uptime, and is treated as invalid after reboot. -- URL param is consumed once and removed from the URL via `history.replaceState`. - -#### Why This Persistence Model - -Bypass persistence intentionally uses `sessionStorage + boot marker` rather than `sessionStorage` alone or `localStorage`. - -`sessionStorage + boot marker` (chosen): - -- Meets operator expectation for partner testing: bypass can survive refresh/navigation in the same browser session. -- Resets after server reboot even if browser tab/session remains open. -- Keeps scope per browser session and avoids cross-session leakage. - -`sessionStorage` only (not chosen): - -- Survives refresh/navigation, but does not reset on reboot if the tab remains open. -- Can leave onboarding unintentionally bypassed after reboot, which conflicts with expected re-check behavior. - -`localStorage` only (not chosen): - -- Persists across browser restarts and future sessions. -- Makes temporary bypass too sticky and increases risk of users forgetting onboarding is still incomplete. -- Harder to reason about during support because bypass state can linger indefinitely on a client. - -Net result: - -- The chosen model gives temporary convenience for active setup workflows while retaining reboot-bound safety and predictable re-entry into onboarding. - -### Core Settings Timezone Precedence - -Component: `web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue` - -The timezone shown/seeded in the core settings step depends on onboarding tracker completion: - -1. If `coreSettingsInitialized === true`, preserve draft timezone exactly (including intentional empty). -2. If onboarding status is still loading, defer timezone override until status is known. -3. If onboarding is initial setup (`onboarding.completed === false`): - - Use non-empty draft timezone first. - - Else use browser timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) if available. - - Else fallback to API baseline timezone. -4. If onboarding is already completed (`onboarding.completed === true`), use API baseline timezone. -5. If nothing is available, fallback chain remains trusted defaults. - -### Regression Test Matrices - -#### A) Summary Step: Core Setting Precedence - -Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts` - -| Case | Baseline available | Draft vs baseline | Expected mutation behavior | -| --- | --- | --- | --- | -| S1 | Yes | No changes | Skip all core-setting mutations. | -| S2 | Yes | One field changed | Only that field's mutation runs. | -| S3 | No | Draft populated | Apply trusted defaults + draft values (best-effort). | -| S4 | No | Draft empty | Apply trusted defaults. | -| S5 | Timed out before baseline ready | Baseline arrives later | Current behavior remains fallback path once timeout tripped. | - -#### B) Summary Step: Plugin Install Matrix - -Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts` - -| Case | Selected plugin state | Installer result | Expected behavior | -| --- | --- | --- | --- | -| P1 | Already installed | N/A | Skip install, show already installed state. | -| P2 | Already installed (case/whitespace variants) | N/A | Skip due to normalized filename dedupe. | -| P3 | Unknown plugin id | N/A | Skip install safely. | -| P4 | Not installed | `SUCCEEDED` | Install logged as success. | -| P5 | Not installed | `FAILED` | Warning path, continue flow. | -| P6 | Not installed | timeout error | Timeout classification path. | - -#### C) Summary Step: Locale/Language Matrix - -Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts` - -| Case | Target locale | Language metadata | Installer result | Expected behavior | -| --- | --- | --- | --- | --- | -| L1 | `en_US` | N/A | N/A | Call `setLocale` directly, no pack install. | -| L2 | non-default | present | `SUCCEEDED` | Install pack then call `setLocale`. | -| L3 | non-default | missing | N/A | Skip locale switch, warning. | -| L4 | non-default | present | `FAILED` | Keep current locale, warning. | -| L5 | non-default | present | malformed/unknown status | Keep current locale, warning. | -| L6 | non-default | present | timeout error | Timeout classification path. | - -#### D) Summary Step: Apply/Result Precedence Matrix - -Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts` - -| Case | Baseline status | Apply status | Other flags | Expected dialog class | -| --- | --- | --- | --- | --- | -| R1 | available | all apply operations succeed | none | Setup Applied | -| R2 | unavailable | best-effort apply path | warnings | Best-Effort | -| R3 | available | some apply operations warn/fail | warnings | Warnings | -| R4 | available or unavailable | timeout during plugin/language install | timeout present | Timeout classification wins | -| R5 | available | SSH verified | none | Fully applied path | -| R6 | available | SSH update submitted | SSH unverified | Best-Effort | - -#### E) Summary Step: Apply Interaction Matrix - -Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts` - -| Case | User action | Expected behavior | -| --- | --- | --- | -| A1 | Click Apply twice while first run in progress | Second click ignored; operations not duplicated; modal lock remains active. | - -#### F) Core Settings Step: Draft Ownership Matrix - -Test file: `web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts` - -| Case | `coreSettingsInitialized` | Baseline payload | Expected behavior | -| --- | --- | --- | --- | -| C1 | false | unavailable | Seed trusted defaults. | -| C2 | false | invalid name/desc | Block submit on validation. | -| C3 | false | valid data | Persist valid settings to draft. | -| C4 | true | baseline has non-empty description | Keep intentionally empty draft description. | -| C5 | true | baseline has timezone/theme/language | Keep intentionally empty initialized draft values. | -| C6 | true | baseline has valid name | Keep initialized empty name invalid (do not auto-fix from baseline). | -| C7 | false + onboarding completed=false | baseline timezone differs from browser | Prefer browser timezone over API baseline. | -| C8 | false + onboarding completed=false | non-empty draft timezone + browser + API present | Prefer draft timezone. | -| C9 | false + onboarding completed=true | browser timezone differs from API | Prefer API baseline timezone. | - -#### G) API Onboarding Service: Init Matrix - -Test file: `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts` - -| Case | Input state | Expected behavior | -| --- | --- | --- | -| B1 | init called twice, tracker complete on second call | First applies, second skips (idempotent). | -| B2 | banner copy fails + case model write fails | Continue flow; do not hard-stop chain. | -| B3 | invalid activation code payload | No customizations applied. | - -#### H) Modal Bypass Matrix - -Test files: -- `web/__test__/store/onboardingModalVisibility.test.ts` -- `web/__test__/components/Onboarding/OnboardingModal.test.ts` - -| Case | Input | Expected behavior | -| --- | --- | --- | -| M1 | Keyboard shortcut entered | Set temporary bypass active for current session/boot; modal hides without completion mutation. | -| M2 | `onboarding=bypass` in URL | Activate temporary bypass and remove param from URL. | -| M3 | `onboarding=resume` in URL | Clear temporary bypass, force modal visibility path, remove param from URL. | -| M4 | Temporary bypass active + upgrade onboarding pending | Modal remains hidden due bypass gate. | -| M5 | Normal close path (no bypass trigger) | Existing completion behavior remains unchanged. | - -### How To Run The Targeted Suites - -From repo root: - -```bash -pnpm --filter @unraid/web exec vitest run __test__/components/Onboarding/OnboardingSummaryStep.test.ts -pnpm --filter @unraid/web exec vitest run __test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts -pnpm --filter @unraid/api exec vitest run src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts -``` diff --git a/web/src/components/Onboarding/components/InternalBootConfirmDialog.vue b/web/src/components/Onboarding/components/InternalBootConfirmDialog.vue index 2a3d064e74..27278ada37 100644 --- a/web/src/components/Onboarding/components/InternalBootConfirmDialog.vue +++ b/web/src/components/Onboarding/components/InternalBootConfirmDialog.vue @@ -14,6 +14,7 @@ const emit = defineEmits<{ }>(); const { t } = useI18n(); +const tpmLicensingFaqUrl = 'https://docs.unraid.net/unraid-os/troubleshooting/tpm-licensing-faq/'; const title = computed(() => props.action === 'reboot' @@ -36,12 +37,36 @@ const title = computed(() => " >