-
Notifications
You must be signed in to change notification settings - Fork 19
feat(web): add standalone internal boot wizard #1956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
4dcb99c
feat(web): add standalone internal boot wizard
elibosley 8daf70b
chore(web): remove auth-request deploy patching
elibosley 648a985
fix(web): defer onboarding refresh until modal close
elibosley acb1816
fix(web): add missing trailing newline to gql/index.ts
Ajit-Mehrotra 3fd8358
test(web): loosen brittle error message assertion in SummaryStep
Ajit-Mehrotra 0fe7a0e
test(web): align applyInternalBootSelection mock type with real signa…
Ajit-Mehrotra 9128f15
fix(web): warn when BIOS boot order update cannot be verified
Ajit-Mehrotra 6af4125
refactor(web): use sleepMs helper instead of inline Promise/setTimeout
Ajit-Mehrotra 1abd331
fix(web): remove duplicate completeOnboarding from SummaryStep
Ajit-Mehrotra 8efa4c3
fix(web): clear draft store when standalone internal boot wizard closes
Ajit-Mehrotra 734417e
refactor(web): extract shared getErrorMessage helper from internalBoo…
Ajit-Mehrotra 488d189
test(web): add missing coverage for standalone boot wizard and compos…
Ajit-Mehrotra 6b9b8da
chore(web): fix lint formatting in test files
Ajit-Mehrotra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
352 changes: 352 additions & 0 deletions
352
web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| import { flushPromises, mount } from '@vue/test-utils'; | ||
|
|
||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { | ||
| InternalBootApplyMessages, | ||
| InternalBootApplyResult, | ||
| InternalBootSelection, | ||
| } from '~/components/Onboarding/composables/internalBoot'; | ||
|
|
||
| import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue'; | ||
| import { createTestI18n } from '../../utils/i18n'; | ||
|
|
||
| const { | ||
| draftStore, | ||
| applyInternalBootSelectionMock, | ||
| cleanupOnboardingStorageMock, | ||
| dialogPropsRef, | ||
| stepPropsRef, | ||
| stepperPropsRef, | ||
| } = vi.hoisted(() => { | ||
| const store = { | ||
| internalBootSelection: null as { | ||
| poolName: string; | ||
| slotCount: number; | ||
| devices: string[]; | ||
| bootSizeMiB: number; | ||
| updateBios: boolean; | ||
| } | null, | ||
| internalBootApplySucceeded: false, | ||
| setInternalBootApplySucceeded: vi.fn((value: boolean) => { | ||
| store.internalBootApplySucceeded = value; | ||
| }), | ||
| }; | ||
|
|
||
| return { | ||
| draftStore: store, | ||
| applyInternalBootSelectionMock: | ||
| vi.fn< | ||
| ( | ||
| selection: InternalBootSelection, | ||
| messages: InternalBootApplyMessages | ||
| ) => Promise<InternalBootApplyResult> | ||
| >(), | ||
| cleanupOnboardingStorageMock: vi.fn(), | ||
| dialogPropsRef: { value: null as Record<string, unknown> | null }, | ||
| stepPropsRef: { value: null as Record<string, unknown> | null }, | ||
| stepperPropsRef: { value: null as Record<string, unknown> | null }, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('@unraid/ui', () => ({ | ||
| Dialog: { | ||
| props: ['modelValue', 'showFooter', 'showCloseButton', 'size', 'class'], | ||
| emits: ['update:modelValue'], | ||
| setup(props: Record<string, unknown>) { | ||
| dialogPropsRef.value = props; | ||
| return { props }; | ||
| }, | ||
| template: ` | ||
| <div data-testid="dialog-stub"> | ||
| <button data-testid="dialog-dismiss" @click="$emit('update:modelValue', false)">Dismiss</button> | ||
| <slot /> | ||
| </div> | ||
| `, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ | ||
| useOnboardingDraftStore: () => draftStore, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/composables/internalBoot', () => ({ | ||
| applyInternalBootSelection: applyInternalBootSelectionMock, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/store/onboardingStorageCleanup', () => ({ | ||
| cleanupOnboardingStorage: cleanupOnboardingStorageMock, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({ | ||
| default: { | ||
| props: ['logs'], | ||
| template: '<div data-testid="onboarding-console">{{ JSON.stringify(logs) }}</div>', | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/OnboardingSteps.vue', () => ({ | ||
| default: { | ||
| props: ['steps', 'activeStepIndex', 'onStepClick'], | ||
| setup(props: Record<string, unknown>) { | ||
| stepperPropsRef.value = props; | ||
| return { props }; | ||
| }, | ||
| template: ` | ||
| <div data-testid="onboarding-steps-stub"> | ||
| {{ props.activeStepIndex }} | ||
| </div> | ||
| `, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@/components/Onboarding/steps/OnboardingInternalBootStep.vue', () => ({ | ||
| default: { | ||
| props: ['onComplete', 'showBack', 'showSkip', 'isSavingStep'], | ||
| setup(props: Record<string, unknown>) { | ||
| stepPropsRef.value = props; | ||
| return { props }; | ||
| }, | ||
| template: ` | ||
| <div data-testid="internal-boot-step-stub"> | ||
| <button data-testid="internal-boot-step-complete" @click="props.onComplete()">Complete</button> | ||
| </div> | ||
| `, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@heroicons/vue/24/solid', () => ({ | ||
| ArrowPathIcon: { template: '<span data-testid="arrow-path-icon" />' }, | ||
| CheckCircleIcon: { template: '<span data-testid="check-circle-icon" />' }, | ||
| ExclamationTriangleIcon: { template: '<span data-testid="warning-icon" />' }, | ||
| XMarkIcon: { template: '<span data-testid="close-icon" />' }, | ||
| })); | ||
|
|
||
| const mountComponent = () => | ||
| mount(OnboardingInternalBootStandalone, { | ||
| global: { | ||
| plugins: [createTestI18n()], | ||
| }, | ||
| }); | ||
|
|
||
| describe('OnboardingInternalBoot.standalone.vue', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| vi.clearAllMocks(); | ||
|
|
||
| draftStore.internalBootSelection = null; | ||
| draftStore.internalBootApplySucceeded = false; | ||
| dialogPropsRef.value = null; | ||
| stepPropsRef.value = null; | ||
| stepperPropsRef.value = null; | ||
| applyInternalBootSelectionMock.mockResolvedValue({ | ||
| applySucceeded: true, | ||
| hadWarnings: false, | ||
| hadNonOptimisticFailures: false, | ||
| logs: [], | ||
| }); | ||
| }); | ||
|
|
||
| it('renders only the internal boot pane in editing mode', () => { | ||
| const wrapper = mountComponent(); | ||
|
|
||
| expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="onboarding-steps-stub"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="onboarding-console"]').exists()).toBe(false); | ||
| expect(dialogPropsRef.value).toMatchObject({ | ||
| modelValue: true, | ||
| showFooter: false, | ||
| showCloseButton: false, | ||
| size: 'full', | ||
| }); | ||
| expect(stepPropsRef.value).toMatchObject({ | ||
| showBack: false, | ||
| showSkip: false, | ||
| isSavingStep: false, | ||
| }); | ||
| expect(stepperPropsRef.value).toMatchObject({ | ||
| activeStepIndex: 0, | ||
| steps: [ | ||
| { id: 'CONFIGURE_BOOT', required: true }, | ||
| { id: 'SUMMARY', required: true }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| 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(); | ||
|
|
||
| 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(stepperPropsRef.value).toMatchObject({ | ||
| activeStepIndex: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('applies the selected internal boot configuration and records success', async () => { | ||
| draftStore.internalBootSelection = { | ||
| poolName: 'cache', | ||
| slotCount: 1, | ||
| devices: ['DISK-A'], | ||
| bootSizeMiB: 16384, | ||
| updateBios: true, | ||
| }; | ||
| applyInternalBootSelectionMock.mockResolvedValue({ | ||
| 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(applyInternalBootSelectionMock).toHaveBeenCalledWith( | ||
| { | ||
| poolName: 'cache', | ||
| devices: ['DISK-A'], | ||
| bootSizeMiB: 16384, | ||
| updateBios: true, | ||
| slotCount: 1, | ||
| }, | ||
| { | ||
| configured: 'Internal boot pool configured.', | ||
| returnedError: expect.any(Function), | ||
| failed: 'Internal boot setup failed', | ||
| 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(stepperPropsRef.value).toMatchObject({ | ||
| activeStepIndex: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('shows retry affordance when the apply helper returns a failure result', async () => { | ||
| draftStore.internalBootSelection = { | ||
| poolName: 'cache', | ||
| slotCount: 1, | ||
| devices: ['DISK-A'], | ||
| bootSizeMiB: 16384, | ||
| updateBios: false, | ||
| }; | ||
| applyInternalBootSelectionMock.mockResolvedValue({ | ||
| applySucceeded: false, | ||
| hadWarnings: true, | ||
| hadNonOptimisticFailures: true, | ||
| 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(); | ||
|
|
||
| expect(wrapper.text()).toContain('Setup Failed'); | ||
| expect(wrapper.text()).toContain('Internal boot setup returned an error: mkbootpool failed'); | ||
| expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true); | ||
|
|
||
| await wrapper.get('[data-testid="internal-boot-standalone-edit-again"]').trigger('click'); | ||
| await flushPromises(); | ||
|
|
||
| expect(stepperPropsRef.value).toMatchObject({ | ||
| activeStepIndex: 0, | ||
| }); | ||
| expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true); | ||
| }); | ||
|
|
||
| it('closes locally after showing a result', async () => { | ||
| const wrapper = mountComponent(); | ||
|
|
||
| await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click'); | ||
| await flushPromises(); | ||
|
|
||
| await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click'); | ||
| await flushPromises(); | ||
|
|
||
| expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(false); | ||
| }); | ||
|
|
||
| it('closes when the shared dialog requests dismissal', async () => { | ||
| const wrapper = mountComponent(); | ||
|
|
||
| await wrapper.get('[data-testid="dialog-dismiss"]').trigger('click'); | ||
| await flushPromises(); | ||
|
|
||
| expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); | ||
| }); | ||
|
|
||
| it('closes via the top-right X button', async () => { | ||
| const wrapper = mountComponent(); | ||
|
|
||
| await wrapper.get('[data-testid="internal-boot-standalone-close"]').trigger('click'); | ||
| await flushPromises(); | ||
|
|
||
| expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); | ||
| expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false); | ||
| }); | ||
|
|
||
| it('shows warning result when apply succeeds with warnings', async () => { | ||
| draftStore.internalBootSelection = { | ||
| poolName: 'boot-pool', | ||
| slotCount: 1, | ||
| devices: ['sda'], | ||
| bootSizeMiB: 512, | ||
| updateBios: true, | ||
| }; | ||
| 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 () => { | ||
| 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(); | ||
|
|
||
| expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assert cleanup on dialog-driven dismissals too.
This close path only proves that the dialog disappears. If
update:modelValue=falseskips the same cleanup handler as the X button/result-close path, stale onboarding draft state will survive and this suite will still pass. Please assertcleanupOnboardingStorageMockhere as well.🧹 Suggested assertion
await wrapper.get('[data-testid="dialog-dismiss"]').trigger('click'); await flushPromises(); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1); expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);Based on learnings, "Test component behavior and output, not implementation details".
📝 Committable suggestion
🤖 Prompt for AI Agents