mirror of
https://github.com/unraid/api.git
synced 2026-04-22 07:10:42 -05:00
9323b14879
## Summary When internal boot configuration is applied during onboarding, the system must reboot (or shut down) to finalize setup — regardless of whether the apply succeeded or failed. Previously, users could escape the reboot path via the X button, back navigation, browser back, a keyboard shortcut (`Ctrl+Alt+Shift+O`), or a URL bypass (`?onboarding=bypass`). This left the system in a potentially broken state if internal boot was partially configured but the user never rebooted. ## Changes ### Lockdown mechanism - Added `internalBootApplyAttempted` flag to the onboarding draft store (persisted to localStorage) - Flag is set `true` when the Summary step begins applying internal boot, **before** the API call — this engages the lockdown immediately - All escape hatches are gated on this flag via a single `isInternalBootLocked` computed ### Wizard lockdown (OnboardingModal.vue) - X close button hidden when locked - Back button hidden on all steps when locked - `handleExitIntent`, `goToPreviousStep` — early return when locked - `goToStep` — blocks backward stepper clicks when locked - `handlePopstate` — calls `window.history.forward()` to neutralize browser back, with an `isProgrammaticHistoryExit` guard so the modal's own `closeModal()` history navigation isn't blocked - Removed keyboard shortcut `Ctrl+Alt+Shift+O` and URL parameter `?onboarding=bypass` entirely (feature hasn't shipped) ### Forced reboot/shutdown on success or failure (OnboardingNextStepsStep.vue) - `showRebootButton` now checks `internalBootSelection !== null` instead of `internalBootApplySucceeded` — reboot shows regardless of outcome - Added **shutdown button** alongside reboot as a secondary option (smaller, text-style) — mirrors the "Skip Setup" / "Get Started" CTA pattern - Shutdown calls the same server shutdown mutation and shows the same confirmation dialog pattern as reboot - Added failure alert: *"Internal boot timed out but is likely setup on your server. Please reboot your system to finalize setup."* - Added BIOS warning when `updateBios` was selected but apply failed — instructs user to manually update BIOS boot order - Made `completeOnboarding()` failure non-blocking for the reboot/shutdown path — wraps in try/catch so users are never stuck ### Standalone lockdown (OnboardingInternalBoot.standalone.vue) - Same lockdown: X hidden, popstate blocked, dialog dismiss blocked - "Edit Again" disabled when locked - "Close" button becomes **two buttons**: "Shutdown" (secondary) and "Reboot" (primary) when locked - BIOS warning shown on failure with `updateBios` selected - Failure message uses same wording as wizard flow ### Log message preservation (SummaryStep + composable) - `returnedError` and `failed` callbacks in `applyInternalBootSelection` preserve the original error output in the user-visible log stream - The generic "timed out / likely setup" message only appears on the Next Steps popup — the actual error details remain in the technical logs ### i18n - 7 new keys in `en.json` for failure messaging, BIOS warnings, shutdown button, and dialog descriptions ## Test plan - [x] All tests pass (64 test files, 628+ tests) - [x] Lint passes (ESLint + Prettier) - [x] Type-check passes (vue-tsc) - [ ] Manual: start onboarding with internal boot eligible → select storage boot → apply → verify lockdown engages - [ ] Manual: simulate API failure → verify reboot AND shutdown buttons still show + failure messaging - [ ] Manual: verify browser back, keyboard shortcut, URL bypass all do nothing during lockdown - [ ] Manual: click Shutdown → confirm it calls shutdown mutation, not reboot - [ ] Manual: test standalone internal boot wizard with same scenarios - [ ] Manual: verify log console still shows specific error details on failure ## Files changed (13) | File | Change | |------|--------| | `store/onboardingDraft.ts` | Added `internalBootApplyAttempted` flag + setter + persistence | | `store/onboardingModalVisibility.ts` | Removed bypass shortcut, URL action, and `bypassOnboarding()` | | `OnboardingModal.vue` | Added lockdown gates on X, back, popstate (with programmatic exit guard), stepper, exit intent | | `composables/internalBoot.ts` | Restored original error output in `returnedError`/`failed` callbacks | | `steps/OnboardingSummaryStep.vue` | Set `internalBootApplyAttempted` before apply | | `steps/OnboardingNextStepsStep.vue` | Forced reboot on failure, shutdown button, failure alerts, BIOS warning, resilient `finishOnboarding` | | `standalone/OnboardingInternalBoot.standalone.vue` | Same lockdown + shutdown/reboot buttons + failure messaging | | `locales/en.json` | 7 new i18n keys | | 5 test files | Updated/added tests for lockdown, shutdown, and failure behavior | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Shutdown alongside Reboot for internal-boot recovery and a confirmation dialog for power actions. * Modal/result UI now shows Reboot/Shutdown actions when internal-boot is locked. * **Improvements** * Locked failure state removes “Edit Again” and blocks close/back/browser-back while internal boot is in progress. * Internal-boot timeout and BIOS-missed messaging updated with new localization keys. * Onboarding flow tracks an “apply attempted” flag to drive UI state. * **Removed** * Keyboard shortcut bypass for onboarding. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
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 OnboardingModal from '~/components/Onboarding/OnboardingModal.vue';
|
|
import { createTestI18n } from '../../utils/i18n';
|
|
|
|
type InternalBootVisibilityResult = {
|
|
value: {
|
|
bootedFromFlashWithInternalBootSetup: boolean | null;
|
|
enableBootTransfer: string | null;
|
|
} | null;
|
|
};
|
|
|
|
const {
|
|
internalBootVisibilityResult,
|
|
internalBootVisibilityLoading,
|
|
onboardingModalStoreState,
|
|
activationCodeDataStore,
|
|
onboardingStatusStore,
|
|
onboardingDraftStore,
|
|
purchaseStore,
|
|
serverStore,
|
|
themeStore,
|
|
cleanupOnboardingStorageMock,
|
|
} = vi.hoisted(() => ({
|
|
internalBootVisibilityResult: {
|
|
value: {
|
|
bootedFromFlashWithInternalBootSetup: false,
|
|
enableBootTransfer: 'yes',
|
|
},
|
|
} as InternalBootVisibilityResult,
|
|
internalBootVisibilityLoading: { 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,
|
|
},
|
|
serverStore: {
|
|
keyfile: { value: null },
|
|
},
|
|
themeStore: {
|
|
fetchTheme: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
cleanupOnboardingStorageMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('pinia', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('pinia')>();
|
|
return {
|
|
...actual,
|
|
storeToRefs: (store: Record<string, unknown>) => store,
|
|
};
|
|
});
|
|
|
|
vi.mock('@unraid/ui', () => ({
|
|
Dialog: {
|
|
name: 'Dialog',
|
|
props: ['modelValue', 'showCloseButton', 'size'],
|
|
emits: ['update:modelValue'],
|
|
template: '<div v-if="modelValue" data-testid="dialog"><slot /></div>',
|
|
},
|
|
Spinner: {
|
|
name: 'Spinner',
|
|
template: '<div data-testid="loading-spinner" />',
|
|
},
|
|
}));
|
|
|
|
vi.mock('@heroicons/vue/24/solid', () => ({
|
|
ArrowTopRightOnSquareIcon: { template: '<svg />' },
|
|
XMarkIcon: { template: '<svg />' },
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({
|
|
default: {
|
|
props: ['steps', 'activeStepIndex'],
|
|
template: '<div data-testid="onboarding-steps" />',
|
|
},
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/stepRegistry', () => ({
|
|
stepComponents: {
|
|
OVERVIEW: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="overview-step"><button data-testid="overview-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="overview-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
CONFIGURE_SETTINGS: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="settings-step"><button data-testid="settings-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="settings-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
CONFIGURE_BOOT: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="internal-boot-step"><button data-testid="internal-boot-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="internal-boot-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
ADD_PLUGINS: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="plugins-step"><button data-testid="plugins-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="plugins-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
ACTIVATE_LICENSE: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="license-step"><button data-testid="license-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="license-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
SUMMARY: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
template:
|
|
'<div data-testid="summary-step"><button data-testid="summary-step-complete" @click="onComplete()">next</button><button v-if="showBack" data-testid="summary-step-back" @click="onBack()">back</button></div>',
|
|
},
|
|
NEXT_STEPS: {
|
|
props: ['onComplete', 'onBack', 'showBack'],
|
|
setup(props: { onComplete: () => void; onBack?: () => void; showBack?: boolean }) {
|
|
const handleClick = () => {
|
|
cleanupOnboardingStorageMock();
|
|
props.onComplete();
|
|
};
|
|
|
|
return {
|
|
handleClick,
|
|
props,
|
|
};
|
|
},
|
|
template:
|
|
'<div data-testid="next-step"><button data-testid="next-step-complete" @click="handleClick">finish</button><button v-if="props.showBack" data-testid="next-step-back" @click="props.onBack?.()">back</button></div>',
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/onboardingModalVisibility', () => ({
|
|
useOnboardingModalStore: () => onboardingModalStoreState,
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/activationCodeData', () => ({
|
|
useActivationCodeDataStore: () => activationCodeDataStore,
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({
|
|
useOnboardingContextDataStore: () => ({
|
|
internalBootVisibility: internalBootVisibilityResult,
|
|
loading: internalBootVisibilityLoading,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({
|
|
useOnboardingStore: () => onboardingStatusStore,
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
|
|
useOnboardingDraftStore: () => onboardingDraftStore,
|
|
}));
|
|
|
|
vi.mock('~/store/purchase', () => ({
|
|
usePurchaseStore: () => purchaseStore,
|
|
}));
|
|
|
|
vi.mock('~/store/server', () => ({
|
|
useServerStore: () => serverStore,
|
|
}));
|
|
|
|
vi.mock('~/store/theme', () => ({
|
|
useThemeStore: () => themeStore,
|
|
}));
|
|
|
|
vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({
|
|
cleanupOnboardingStorage: cleanupOnboardingStorageMock,
|
|
}));
|
|
|
|
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<string | null>('ENOKEYFILE');
|
|
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',
|
|
};
|
|
});
|
|
|
|
const mountComponent = () =>
|
|
mount(OnboardingModal, {
|
|
global: {
|
|
plugins: [createTestI18n()],
|
|
},
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('renders when backend visibility is enabled', () => {
|
|
const wrapper = mountComponent();
|
|
|
|
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);
|
|
});
|
|
|
|
it('does not render when backend visibility is disabled', () => {
|
|
onboardingModalStoreState.isVisible.value = false;
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
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();
|
|
|
|
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('shows the activation step for ENOKEYFILE1', () => {
|
|
activationCodeDataStore.registrationState.value = 'ENOKEYFILE1';
|
|
onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE';
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('hides the internal boot step when boot transfer is unavailable', () => {
|
|
internalBootVisibilityResult.value = {
|
|
bootedFromFlashWithInternalBootSetup: false,
|
|
enableBootTransfer: 'no',
|
|
};
|
|
onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT';
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(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',
|
|
};
|
|
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 wrapper = mountComponent();
|
|
|
|
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 flushPromises();
|
|
|
|
expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true);
|
|
expect(onboardingDraftStore.currentStepId.value).toBe('ACTIVATE_LICENSE');
|
|
});
|
|
|
|
it('opens exit confirmation when close button is clicked', async () => {
|
|
const wrapper = mountComponent();
|
|
|
|
await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
|
|
|
|
expect(wrapper.text()).toContain('Exit onboarding?');
|
|
expect(wrapper.text()).toContain('Exit setup');
|
|
});
|
|
|
|
it('shows the internal boot restart guidance when exiting after internal boot was applied', async () => {
|
|
onboardingDraftStore.internalBootApplySucceeded.value = true;
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
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');
|
|
});
|
|
|
|
it('closes onboarding through the backend-owned close path', async () => {
|
|
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(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();
|
|
});
|
|
|
|
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:
|
|
| {
|
|
promise: Promise<boolean>;
|
|
resolve: (value: boolean) => void;
|
|
}
|
|
| undefined;
|
|
onboardingModalStoreState.closeModal.mockImplementation(() => {
|
|
let resolve!: (value: boolean) => void;
|
|
const promise = new Promise<boolean>((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(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();
|
|
});
|
|
|
|
it('closes onboarding without frontend completion logic', async () => {
|
|
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(exitButton).toBeTruthy();
|
|
await exitButton!.trigger('click');
|
|
await flushPromises();
|
|
|
|
expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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';
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('hides the X button when internal boot lockdown is active', () => {
|
|
onboardingDraftStore.internalBootApplyAttempted.value = true;
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('passes showBack=false to step components when internal boot lockdown is active', async () => {
|
|
onboardingDraftStore.internalBootApplyAttempted.value = true;
|
|
onboardingDraftStore.currentStepId.value = 'CONFIGURE_SETTINGS';
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('does not open exit confirmation when locked and X area is somehow triggered', async () => {
|
|
onboardingDraftStore.internalBootApplyAttempted.value = true;
|
|
|
|
const wrapper = mountComponent();
|
|
|
|
expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false);
|
|
expect(wrapper.text()).not.toContain('Exit onboarding?');
|
|
});
|
|
});
|