diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts index d05237ed8d..246eb1cc36 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts @@ -1,6 +1,6 @@ -import { flushPromises, mount } from '@vue/test-utils'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { InternalBootApplyMessages, @@ -11,6 +11,14 @@ import type { import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue'; import { createTestI18n } from '../../utils/i18n'; +const INTERNAL_BOOT_HISTORY_STATE_KEY = '__unraidOnboardingInternalBoot'; + +type InternalBootHistoryState = { + sessionId: string; + stepId: 'CONFIGURE_BOOT' | 'SUMMARY'; + position: number; +}; + const { draftStore, applyInternalBootSelectionMock, @@ -129,10 +137,50 @@ const mountComponent = () => }, }); +enableAutoUnmount(afterEach); + +const getInternalBootHistoryState = (): InternalBootHistoryState | null => { + const state = + typeof window.history.state === 'object' && window.history.state !== null + ? (window.history.state as Record) + : null; + const candidate = state?.[INTERNAL_BOOT_HISTORY_STATE_KEY]; + if (!candidate || typeof candidate !== 'object') { + return null; + } + + const sessionId = + typeof (candidate as Record).sessionId === 'string' + ? (candidate as Record).sessionId + : null; + const stepId = + (candidate as Record).stepId === 'CONFIGURE_BOOT' || + (candidate as Record).stepId === 'SUMMARY' + ? ((candidate as Record).stepId as InternalBootHistoryState['stepId']) + : null; + const position = Number((candidate as Record).position); + + if (!sessionId || !stepId || !Number.isInteger(position)) { + return null; + } + + return { + sessionId, + stepId, + position, + }; +}; + +const dispatchPopstate = (state: Record | null) => { + window.history.replaceState(state, '', window.location.href); + window.dispatchEvent(new PopStateEvent('popstate', { state })); +}; + describe('OnboardingInternalBoot.standalone.vue', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + window.history.replaceState(null, '', window.location.href); draftStore.internalBootSelection = null; draftStore.internalBootApplySucceeded = false; @@ -182,8 +230,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => { expect(applyInternalBootSelectionMock).not.toHaveBeenCalled(); expect(draftStore.setInternalBootApplySucceeded).toHaveBeenCalledWith(false); - expect(wrapper.text()).toContain('Setup Applied'); - expect(wrapper.text()).toContain('No settings changed. Skipping configuration mutations.'); + 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); expect(stepperPropsRef.value).toMatchObject({ activeStepIndex: 1, }); @@ -278,7 +327,69 @@ describe('OnboardingInternalBoot.standalone.vue', () => { expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true); }); + 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(); + + const currentHistoryState = getInternalBootHistoryState(); + expect(currentHistoryState).toMatchObject({ + stepId: 'SUMMARY', + position: 1, + }); + + dispatchPopstate({ + [INTERNAL_BOOT_HISTORY_STATE_KEY]: { + sessionId: currentHistoryState?.sessionId, + stepId: 'CONFIGURE_BOOT', + position: 0, + }, + }); + await flushPromises(); + await wrapper.vm.$nextTick(); + + expect(stepperPropsRef.value).toMatchObject({ + activeStepIndex: 0, + }); + expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true); + }); + + it('closes when browser back leaves a fully applied result', async () => { + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 1, + devices: ['DISK-A'], + bootSizeMiB: 16384, + updateBios: true, + }; + + const wrapper = mountComponent(); + + await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); + await flushPromises(); + + const currentHistoryState = getInternalBootHistoryState(); + expect(currentHistoryState).toMatchObject({ + stepId: 'SUMMARY', + position: 1, + }); + + dispatchPopstate({ + [INTERNAL_BOOT_HISTORY_STATE_KEY]: { + sessionId: currentHistoryState?.sessionId, + stepId: 'CONFIGURE_BOOT', + position: 0, + }, + }); + await flushPromises(); + + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); + }); + it('closes locally after showing a result', async () => { + const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {}); const wrapper = mountComponent(); await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); @@ -287,24 +398,38 @@ describe('OnboardingInternalBoot.standalone.vue', () => { await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click'); await flushPromises(); + expect(historyGoSpy).toHaveBeenCalledWith(-2); + 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(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); }); it('closes via the top-right X button', async () => { + const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {}); const wrapper = mountComponent(); await wrapper.get('[data-testid="internal-boot-standalone-close"]').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); }); @@ -337,6 +462,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => { }); 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'); @@ -347,6 +473,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => { await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click'); await flushPromises(); + dispatchPopstate(null); + await flushPromises(); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts index 444df48b5d..1dc0268ee0 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts @@ -341,15 +341,13 @@ describe('OnboardingInternalBootStep', () => { await flushPromises(); expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined(); + expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false); expect(wrapper.find('[data-testid="internal-boot-drive-warning"]').exists()).toBe(false); const selects = wrapper.findAll('select'); await selects[1]?.setValue('ELIGIBLE-1'); await flushPromises(); expect(wrapper.find('[data-testid="internal-boot-drive-warning"]').exists()).toBe(true); expect(wrapper.text()).toContain('Selected drive warnings'); - await wrapper.get('[data-testid="internal-boot-eligibility-toggle"]').trigger('click'); - await flushPromises(); - expect(wrapper.text()).toContain('HAS_INTERNAL_BOOT_PARTITIONS'); }); it('shows disk-level ineligibility while keeping the form available for eligible disks', async () => { diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 3631e8a691..58bc196d34 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -36,6 +36,7 @@ const { internalBootVisibilityLoading: { value: false }, onboardingModalStoreState: { isVisible: { value: true }, + sessionSource: { value: 'automatic' as 'automatic' | 'manual' }, closeModal: vi.fn().mockResolvedValue(true), }, activationCodeDataStore: { @@ -112,15 +113,39 @@ vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({ vi.mock('~/components/Onboarding/stepRegistry', () => ({ stepComponents: { - OVERVIEW: { template: '
' }, - CONFIGURE_SETTINGS: { template: '
' }, - CONFIGURE_BOOT: { template: '
' }, - ADD_PLUGINS: { template: '
' }, - ACTIVATE_LICENSE: { template: '
' }, - SUMMARY: { template: '
' }, + OVERVIEW: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, + CONFIGURE_SETTINGS: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, + CONFIGURE_BOOT: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, + ADD_PLUGINS: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, + ACTIVATE_LICENSE: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, + SUMMARY: { + props: ['onComplete', 'onBack', 'showBack'], + template: + '
', + }, NEXT_STEPS: { - props: ['onComplete'], - setup(props: { onComplete: () => void }) { + props: ['onComplete', 'onBack', 'showBack'], + setup(props: { onComplete: () => void; onBack?: () => void; showBack?: boolean }) { const handleClick = () => { cleanupOnboardingStorageMock(); props.onComplete(); @@ -128,10 +153,11 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({ return { handleClick, + props, }; }, template: - '
', + '
', }, }, })); @@ -181,6 +207,7 @@ describe('OnboardingModal.vue', () => { onboardingModalStoreState.closeModal.mockImplementation(async () => { onboardingModalStoreState.isVisible.value = false; + onboardingModalStoreState.sessionSource.value = 'automatic'; return true; }); @@ -189,6 +216,7 @@ describe('OnboardingModal.vue', () => { activationCodeDataStore.hasActivationCode = ref(true); activationCodeDataStore.registrationState = ref('ENOKEYFILE'); onboardingModalStoreState.isVisible.value = true; + onboardingModalStoreState.sessionSource.value = 'automatic'; activationCodeDataStore.registrationState.value = 'ENOKEYFILE'; onboardingStatusStore.isVersionDrift.value = false; onboardingStatusStore.completedAtVersion.value = null; @@ -355,6 +383,26 @@ describe('OnboardingModal.vue', () => { expect(onboardingDraftStore.currentStepId.value).toBeNull(); }); + it('closes a manually opened wizard at the end instead of reloading the page', async () => { + onboardingModalStoreState.sessionSource.value = 'manual'; + onboardingDraftStore.currentStepId.value = 'NEXT_STEPS'; + const goSpy = vi.spyOn(window.history, 'go').mockImplementation(() => undefined); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.get('[data-testid="next-step-complete"]').trigger('click'); + await flushPromises(); + + expect(goSpy).toHaveBeenCalledWith(-1); + expect(onboardingModalStoreState.closeModal).not.toHaveBeenCalled(); + + window.dispatchEvent(new PopStateEvent('popstate', { state: null })); + await flushPromises(); + + expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1); + }); + it('shows a loading state while exit confirmation is closing the modal', async () => { let closeModalDeferred: | { diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index 2a8050fe5f..13dcc2030f 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -380,7 +380,7 @@ describe('OnboardingSummaryStep', () => { assertExpected: (wrapper: ReturnType['wrapper']) => { expect(installPluginMock).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('Already installed'); - expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).toContain('No Updates Needed'); }, }, { @@ -392,7 +392,7 @@ describe('OnboardingSummaryStep', () => { assertExpected: (wrapper: ReturnType['wrapper']) => { expect(installPluginMock).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('Already installed'); - expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).toContain('No Updates Needed'); }, }, { @@ -402,7 +402,7 @@ describe('OnboardingSummaryStep', () => { }, assertExpected: (wrapper: ReturnType['wrapper']) => { expect(installPluginMock).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).toContain('No Updates Needed'); }, }, { @@ -857,7 +857,7 @@ describe('OnboardingSummaryStep', () => { apply: () => {}, assertExpected: (wrapper: ReturnType['wrapper']) => { expect(completeOnboardingMock).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).toContain('No Updates Needed'); expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode'); }, }, @@ -889,7 +889,7 @@ describe('OnboardingSummaryStep', () => { expect(completeOnboardingMock).not.toHaveBeenCalled(); expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true); - expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).toContain('No Updates Needed'); expect(onComplete).not.toHaveBeenCalled(); }); diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 7f26d15656..39a27b7d1f 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -1,5 +1,5 @@