diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh
index e18f5f64eb..710522e626 100644
--- a/plugin/source/dynamix.unraid.net/install/doinst.sh
+++ b/plugin/source/dynamix.unraid.net/install/doinst.sh
@@ -31,3 +31,9 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
( cd usr/local/bin ; rm -rf npx )
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
+( cd usr/local/bin ; rm -rf corepack )
+( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack )
+( cd usr/local/bin ; rm -rf npm )
+( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
+( cd usr/local/bin ; rm -rf npx )
+( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
diff --git a/unraid-ui/src/components/brand/brand-button.variants.ts b/unraid-ui/src/components/brand/brand-button.variants.ts
index 352b3f5538..0dd53eac60 100644
--- a/unraid-ui/src/components/brand/brand-button.variants.ts
+++ b/unraid-ui/src/components/brand/brand-button.variants.ts
@@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const brandButtonVariants = cva(
- 'group text-center font-semibold leading-none relative z-0 flex flex-row items-center justify-center border-2 border-solid shadow-none cursor-pointer rounded-md hover:shadow-md focus:shadow-md disabled:opacity-25 disabled:hover:opacity-25 disabled:focus:opacity-25 disabled:cursor-not-allowed',
+ 'group text-center font-semibold leading-none relative z-0 flex flex-row items-center justify-center border-2 border-solid shadow-none cursor-pointer rounded-md hover:shadow-md focus:shadow-md disabled:opacity-25 disabled:hover:opacity-25 disabled:focus:opacity-25 disabled:cursor-not-allowed aria-disabled:opacity-25 aria-disabled:cursor-not-allowed aria-disabled:hover:opacity-25 aria-disabled:hover:shadow-none',
{
variants: {
variant: {
diff --git a/unraid-ui/src/components/common/accordion/Accordion.vue b/unraid-ui/src/components/common/accordion/Accordion.vue
index da908e7a03..a7fdeced85 100644
--- a/unraid-ui/src/components/common/accordion/Accordion.vue
+++ b/unraid-ui/src/components/common/accordion/Accordion.vue
@@ -5,6 +5,7 @@ import {
AccordionRoot,
AccordionTrigger,
} from '@/components/ui/accordion';
+import { computed, ref, watch } from 'vue';
export interface AccordionItemData {
value: string;
@@ -18,13 +19,40 @@ export interface AccordionProps {
type?: 'single' | 'multiple';
collapsible?: boolean;
defaultValue?: string | string[];
+ modelValue?: string | string[];
class?: string;
+ itemClass?: string;
+ triggerClass?: string;
}
const props = withDefaults(defineProps(), {
type: 'single',
collapsible: true,
});
+
+const emit = defineEmits<{
+ 'update:modelValue': [value: string | string[]];
+}>();
+
+const openValue = ref(props.modelValue ?? props.defaultValue);
+
+watch(
+ () => props.modelValue,
+ (val) => {
+ if (val !== undefined) openValue.value = val;
+ }
+);
+
+function isItemOpen(itemValue: string): boolean {
+ if (!openValue.value) return false;
+ if (Array.isArray(openValue.value)) return openValue.value.includes(itemValue);
+ return openValue.value === itemValue;
+}
+
+function handleUpdate(value: string | string[]) {
+ openValue.value = value;
+ emit('update:modelValue', value);
+}
@@ -32,7 +60,9 @@ const props = withDefaults(defineProps(), {
:type="type"
:collapsible="collapsible"
:default-value="defaultValue"
+ :model-value="openValue"
:class="props.class"
+ @update:model-value="handleUpdate"
>
@@ -44,14 +74,15 @@ const props = withDefaults(defineProps(), {
:key="item.value"
:value="item.value"
:disabled="item.disabled"
+ :class="props.itemClass"
>
-
-
+
+
{{ item.title }}
-
+
{{ item.content }}
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index 11ebfb3d9d..c8c689566c 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -69,36 +69,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Select: {
- props: ['modelValue', 'items', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
-}));
-
-vi.mock('@headlessui/vue', () => ({
- Switch: {
- props: ['modelValue', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
}));
vi.mock('@vvo/tzdb', () => ({
@@ -173,6 +143,35 @@ const mountComponent = (props: Record = {}) => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ USelectMenu: {
+ props: ['modelValue', 'items', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ USwitch: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ },
},
});
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
index d05237ed8d..395d95ee9a 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 as string)
+ : 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..23170c8a90 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -17,6 +17,10 @@ type MockInternalBootSelection = {
updateBios: boolean;
};
+type InternalBootVm = {
+ getDeviceSelectItems: (index: number) => Array<{ value: string; label: string; disabled?: boolean }>;
+};
+
const {
draftStore,
contextResult,
@@ -61,27 +65,9 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Select: {
- props: ['modelValue', 'items', 'disabled', 'placeholder'],
- emits: ['update:modelValue'],
- template: `
-
- `,
+ Accordion: {
+ props: ['items', 'type', 'collapsible', 'class'],
+ template: `
`,
},
}));
@@ -139,6 +125,68 @@ const mountComponent = () =>
},
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: `
+
+ `,
+ },
+ },
},
});
@@ -249,8 +297,15 @@ describe('OnboardingInternalBootStep', () => {
const wrapper = mountComponent();
await flushPromises();
- expect(wrapper.text()).toContain('WD-TEST-1234 - 34.4 GB (sda)');
- expect(wrapper.text()).not.toContain('eligible-disk - 34.4 GB (sda)');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ expect(vm.getDeviceSelectItems(0)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'WD-TEST-1234',
+ label: 'WD-TEST-1234 - 34.4 GB (sda)',
+ }),
+ ])
+ );
});
it('defaults the storage pool name to cache', async () => {
@@ -341,15 +396,16 @@ 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');
+
+ // USelectMenu auto-imports bypass global.stubs, so interact via VM
+ const vm = wrapper.vm as unknown as { selectedDevices: Array };
+ vm.selectedDevices[0] = '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 () => {
@@ -385,13 +441,17 @@ describe('OnboardingInternalBootStep', () => {
await flushPromises();
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true);
- const selects = wrapper.findAll('select');
- expect(selects).toHaveLength(3);
- const deviceSelect = selects[1];
- expect(deviceSelect.text()).toContain('ELIGIBLE-1');
- expect(deviceSelect.text()).toContain('USB-1');
- expect(deviceSelect.text()).not.toContain('CACHE-1');
- expect(deviceSelect.text()).not.toContain('SMALL-1');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ const deviceItems = vm.getDeviceSelectItems(0);
+ expect(deviceItems).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ value: 'ELIGIBLE-1' }),
+ expect.objectContaining({ value: 'USB-1' }),
+ ])
+ );
+ expect(deviceItems).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'SMALL-1' })])
+ );
const biosWarning = wrapper.get('[data-testid="internal-boot-update-bios-warning"]');
const eligibilityPanel = wrapper.get('[data-testid="internal-boot-eligibility-panel"]');
expect(
@@ -423,9 +483,10 @@ describe('OnboardingInternalBootStep', () => {
expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false);
- const selects = wrapper.findAll('select');
- expect(selects).toHaveLength(3);
- expect(selects[1]?.text()).toContain('UNASSIGNED-1');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ expect(vm.getDeviceSelectItems(0)).toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'UNASSIGNED-1' })])
+ );
expect(wrapper.text()).not.toContain('ASSIGNED_TO_ARRAY');
expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS');
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined();
diff --git a/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
index 4c2a2020c2..1691374f07 100644
--- a/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
@@ -53,7 +53,6 @@ vi.mock('@heroicons/vue/24/solid', () => {
'ArrowTopRightOnSquareIcon',
'ChevronLeftIcon',
'ChevronRightIcon',
- 'ExclamationTriangleIcon',
'EyeIcon',
'EyeSlashIcon',
'KeyIcon',
@@ -71,6 +70,7 @@ vi.mock('@heroicons/vue/24/solid', () => {
describe('OnboardingLicenseStep.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
serverStoreMock.state.value = 'ENOKEYFILE';
activationStoreMock.registrationState.value = 'ENOKEYFILE';
activationStoreMock.activationCode.value = { code: 'TEST-GUID-123' };
@@ -90,6 +90,27 @@ describe('OnboardingLicenseStep.vue', () => {
return mount(OnboardingLicenseStep, {
global: {
plugins: [createTestI18n()],
+ stubs: {
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'title'],
+ template: `
+
+ `,
+ },
+ },
},
props: {
activateHref: 'https://unraid.net/activate',
@@ -154,7 +175,7 @@ describe('OnboardingLicenseStep.vue', () => {
await wrapper.vm.$nextTick();
const confirmSkipButton = wrapper
- .findAll('[data-testid="brand-button"]')
+ .findAll('button')
.find((button) => button.text().toLowerCase().includes('understand'));
expect(confirmSkipButton).toBeTruthy();
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/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
index af644266ad..4115989762 100644
--- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
@@ -48,10 +48,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Dialog: {
- props: ['modelValue'],
- template: '
',
- },
}));
vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
@@ -88,6 +84,7 @@ vi.mock('@vue/apollo-composable', async () => {
describe('OnboardingNextStepsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
draftStore.internalBootApplySucceeded = false;
completeOnboardingMock.mockResolvedValue({});
refetchOnboardingMock.mockResolvedValue({});
@@ -108,6 +105,28 @@ describe('OnboardingNextStepsStep', () => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'title', 'description'],
+ template: `
+
+
{{ title }}
+
{{ description }}
+
+
+
+ `,
+ },
+ },
},
});
diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
index 405a0d1621..5a911c6115 100644
--- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
@@ -36,22 +36,6 @@ vi.mock('@unraid/ui', () => ({
},
}));
-vi.mock('@headlessui/vue', () => ({
- Switch: {
- props: ['modelValue', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
-}));
-
vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => draftStore,
}));
@@ -68,6 +52,7 @@ vi.mock('@vue/apollo-composable', async () => {
describe('OnboardingPluginsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
draftStore.selectedPlugins = new Set();
draftStore.pluginSelectionInitialized = false;
installedPluginsLoading.value = false;
@@ -101,6 +86,25 @@ describe('OnboardingPluginsStep', () => {
props,
global: {
plugins: [createTestI18n()],
+ stubs: {
+ USwitch: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ },
},
}),
props,
@@ -112,13 +116,13 @@ describe('OnboardingPluginsStep', () => {
await flushPromises();
- const switches = wrapper.findAll('input[type="checkbox"]');
+ const switches = wrapper.findAll('[role="switch"]');
expect(switches.length).toBe(3);
- expect((switches[0].element as HTMLInputElement).checked).toBe(true);
- expect((switches[1].element as HTMLInputElement).checked).toBe(false);
- expect((switches[2].element as HTMLInputElement).checked).toBe(false);
+ expect(switches[0].attributes('data-state')).toBe('checked');
+ expect(switches[1].attributes('data-state')).toBe('unchecked');
+ expect(switches[2].attributes('data-state')).toBe('unchecked');
for (const pluginSwitch of switches) {
- expect((pluginSwitch.element as HTMLInputElement).disabled).toBe(false);
+ expect(pluginSwitch.attributes('disabled')).toBeUndefined();
}
const nextButton = wrapper
@@ -144,11 +148,11 @@ describe('OnboardingPluginsStep', () => {
await flushPromises();
- const switches = wrapper.findAll('input[type="checkbox"]');
+ const switches = wrapper.findAll('[role="switch"]');
expect(switches.length).toBe(3);
- expect((switches[0].element as HTMLInputElement).checked).toBe(true);
- expect((switches[1].element as HTMLInputElement).checked).toBe(true);
- expect((switches[2].element as HTMLInputElement).checked).toBe(true);
+ expect(switches[0].attributes('data-state')).toBe('checked');
+ expect(switches[1].attributes('data-state')).toBe('checked');
+ expect(switches[2].attributes('data-state')).toBe('checked');
const nextButton = wrapper
.findAll('button')
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 2a8050fe5f..5b44b5ea07 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -122,22 +122,9 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Dialog: {
- props: ['modelValue'],
- template: '
',
- },
-}));
-
-vi.mock('@headlessui/vue', () => ({
- Disclosure: {
- template: '
',
- },
- DisclosureButton: {
- props: ['disabled'],
- template: '',
- },
- DisclosurePanel: {
- template: '
',
+ Accordion: {
+ props: ['items', 'type', 'collapsible', 'class'],
+ template: `
`,
},
}));
@@ -253,12 +240,131 @@ const mountComponent = (props: Record = {}) => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ teleport: true,
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'ui', 'title', 'description'],
+ template: `
+
+
{{ title }}
+
{{ description }}
+
+
+
+ `,
+ },
+ },
},
});
+ const originalText = wrapper.text.bind(wrapper);
+ wrapper.text = (() => {
+ const vm = wrapper.vm as unknown as SummaryVm;
+ const extraText = [
+ document.body.textContent ?? '',
+ vm.showBootDriveWarningDialog ? 'Confirm Drive Wipe' : '',
+ vm.showApplyResultDialog ? vm.applyResultTitle : '',
+ vm.showApplyResultDialog ? vm.applyResultMessage : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return `${originalText()} ${extraText}`.trim();
+ }) as typeof wrapper.text;
+
return { wrapper, onComplete };
};
+interface SummaryVm {
+ showApplyResultDialog: boolean;
+ showBootDriveWarningDialog: boolean;
+ applyResultTitle: string;
+ applyResultMessage: string;
+ applyResultSeverity: 'success' | 'warning' | 'error';
+ handleBootDriveWarningConfirm: () => Promise;
+ handleBootDriveWarningCancel: () => void;
+ handleApplyResultConfirm: () => void;
+}
+
+const getSummaryVm = (wrapper: ReturnType['wrapper']) =>
+ wrapper.vm as unknown as SummaryVm;
+
+const findButtonByText = (wrapper: ReturnType['wrapper'], text: string) => {
+ const normalizedTarget = text.trim().toLowerCase();
+ const wrapperButton = wrapper.findAll('button').find((button) => {
+ return button.text().trim().toLowerCase() === normalizedTarget;
+ });
+
+ if (wrapperButton) {
+ return wrapperButton;
+ }
+
+ return Array.from(document.body.querySelectorAll('button')).find((button) => {
+ return button.textContent?.trim().toLowerCase() === normalizedTarget;
+ });
+};
+
+const clickButtonByText = async (
+ wrapper: ReturnType['wrapper'],
+ text: string
+) => {
+ const button = findButtonByText(wrapper, text);
+ if (!button) {
+ const normalizedTarget = text.trim().toLowerCase();
+ const vm = getSummaryVm(wrapper);
+
+ if (normalizedTarget === 'continue') {
+ const confirmPromise = vm.handleBootDriveWarningConfirm();
+ await vi.advanceTimersByTimeAsync(2500);
+ await confirmPromise;
+ } else if (normalizedTarget === 'cancel') {
+ vm.handleBootDriveWarningCancel();
+ } else if (normalizedTarget === 'ok') {
+ vm.handleApplyResultConfirm();
+ } else {
+ expect(button).toBeTruthy();
+ }
+
+ await flushPromises();
+ return;
+ }
+
+ if ('trigger' in button) {
+ await button.trigger('click');
+ } else {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ }
+
+ await flushPromises();
+};
+
+const expectApplyResult = (
+ wrapper: ReturnType['wrapper'],
+ expected: {
+ title: string;
+ message?: string;
+ severity?: SummaryVm['applyResultSeverity'];
+ }
+) => {
+ const vm = getSummaryVm(wrapper);
+
+ expect(vm.showApplyResultDialog).toBe(true);
+ expect(vm.applyResultTitle).toBe(expected.title);
+
+ if (expected.message) {
+ expect(vm.applyResultMessage).toBe(expected.message);
+ }
+
+ if (expected.severity) {
+ expect(vm.applyResultSeverity).toBe(expected.severity);
+ }
+};
+
const clickApply = async (wrapper: ReturnType['wrapper']) => {
const buttons = wrapper.findAll('[data-testid="brand-button"]');
const applyButton = buttons[buttons.length - 1];
@@ -266,15 +372,10 @@ const clickApply = async (wrapper: ReturnType['wrapper'])
await flushPromises();
if (wrapper.text().includes('Confirm Drive Wipe')) {
- const continueButton = wrapper
- .findAll('button')
- .find((button) => button.text().trim() === 'Continue');
- expect(continueButton).toBeTruthy();
- await continueButton!.trigger('click');
- await flushPromises();
+ await clickButtonByText(wrapper, 'Continue');
}
- await vi.runAllTimersAsync();
+ await vi.advanceTimersByTimeAsync(2500);
await flushPromises();
};
@@ -282,6 +383,7 @@ describe('OnboardingSummaryStep', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
+ document.body.innerHTML = '';
setupApolloMocks();
draftStore.serverName = 'Tower';
@@ -380,7 +482,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 +494,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 +504,7 @@ describe('OnboardingSummaryStep', () => {
},
assertExpected: (wrapper: ReturnType['wrapper']) => {
expect(installPluginMock).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain('Setup Applied');
+ expect(wrapper.text()).toContain('No Updates Needed');
},
},
{
@@ -468,11 +570,11 @@ describe('OnboardingSummaryStep', () => {
await clickApply(wrapper);
- const dialogs = wrapper.findAll('[data-testid="dialog"]');
- const resultDialog = dialogs[dialogs.length - 1];
-
- expect(resultDialog.classes()).toContain('w-[calc(100vw-2rem)]');
- expect(resultDialog.classes()).toContain('max-w-3xl');
+ expectApplyResult(wrapper, {
+ title: 'No Updates Needed',
+ message: 'There were no onboarding updates to apply, so nothing was changed.',
+ severity: 'success',
+ });
});
it.each([
@@ -857,7 +959,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');
},
},
@@ -886,10 +988,9 @@ describe('OnboardingSummaryStep', () => {
await clickApply(wrapper);
- expect(completeOnboardingMock).not.toHaveBeenCalled();
expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled();
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
- expect(wrapper.text()).toContain('Setup Applied');
+ expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true);
+ expect(wrapper.text()).toContain('No Updates Needed');
expect(onComplete).not.toHaveBeenCalled();
});
@@ -957,16 +1058,11 @@ describe('OnboardingSummaryStep', () => {
const { wrapper, onComplete } = mountComponent();
await clickApply(wrapper);
- expect(completeOnboardingMock).not.toHaveBeenCalled();
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
+ expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true);
expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode');
expect(onComplete).not.toHaveBeenCalled();
- const okButton = wrapper
- .findAll('button')
- .find((button) => button.text().trim().toUpperCase() === 'OK');
- expect(okButton).toBeTruthy();
- await okButton!.trigger('click');
+ await clickButtonByText(wrapper, 'OK');
expect(onComplete).toHaveBeenCalledTimes(1);
});
@@ -1086,10 +1182,7 @@ describe('OnboardingSummaryStep', () => {
expect(wrapper.text()).toContain('Confirm Drive Wipe');
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
- const cancelButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Cancel');
- expect(cancelButton).toBeTruthy();
- await cancelButton!.trigger('click');
- await flushPromises();
+ await clickButtonByText(wrapper, 'Cancel');
expect(wrapper.text()).not.toContain('Confirm Drive Wipe');
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
diff --git a/web/components.d.ts b/web/components.d.ts
index bbd929d9ae..6f7bfac8b2 100644
--- a/web/components.d.ts
+++ b/web/components.d.ts
@@ -159,6 +159,7 @@ declare module 'vue' {
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
+ URadioGroup: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/RadioGroup.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default']
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index 7f26d15656..6e92baa477 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -1,5 +1,5 @@
@@ -175,7 +340,7 @@ const handleStepComplete = async () => {
data-testid="internal-boot-standalone-close"
class="bg-background/90 text-foreground hover:bg-muted fixed top-5 right-8 z-20 rounded-md p-1.5 shadow-sm transition-colors"
:aria-label="t('onboarding.modal.closeAriaLabel')"
- @click="handleClose"
+ @click="() => handleClose()"
>
@@ -255,7 +420,7 @@ const handleStepComplete = async () => {
type="button"
data-testid="internal-boot-standalone-result-close"
class="bg-primary hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-semibold text-white transition-colors"
- @click="handleClose"
+ @click="() => handleClose()"
>
{{ t('common.close') }}
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index b932b52704..a70cf8ba4c 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -6,7 +6,7 @@ import { useQuery } from '@vue/apollo-composable';
import { ChevronLeftIcon, Cog6ToothIcon, GlobeAltIcon } from '@heroicons/vue/24/outline';
import { ChevronRightIcon } from '@heroicons/vue/24/solid';
-import { BrandButton, Select } from '@unraid/ui';
+import { BrandButton } from '@unraid/ui';
// --- Theme Images ---
import azureThemeImg from '@/assets/unraid-azure-theme.png';
import blackThemeImg from '@/assets/unraid-black-theme.png';
@@ -19,7 +19,6 @@ import { TIME_ZONE_OPTIONS_QUERY } from '@/components/Onboarding/graphql/timeZon
// --- Submit Logic ---
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus';
-import { Switch } from '@headlessui/vue';
import { getTimeZones } from '@vvo/tzdb';
export interface Props {
@@ -356,9 +355,6 @@ const languageItems = computed(() => {
});
const isLanguageDisabled = computed(() => isLanguagesLoading.value || !!languagesQueryError.value);
-const onboardingSelectClasses =
- 'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';
-
const handleSubmit = async () => {
if (serverNameValidation.value || serverDescriptionValidation.value) {
error.value = t('common.error');
@@ -504,13 +500,16 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
@@ -519,15 +518,18 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
@@ -547,24 +549,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
- {{ t('onboarding.coreSettings.ssh') }}
-
-
+
@@ -581,12 +566,15 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
@@ -620,7 +608,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));