diff --git a/web/__test__/components/ChangelogModal.test.ts b/web/__test__/components/ChangelogModal.test.ts index 6c6772405e..2e0603a378 100644 --- a/web/__test__/components/ChangelogModal.test.ts +++ b/web/__test__/components/ChangelogModal.test.ts @@ -11,13 +11,14 @@ vi.mock('@unraid/ui', () => ({ BrandButton: { template: '' }, BrandLoading: { template: '
' }, cn: (...classes: string[]) => classes.filter(Boolean).join(' '), - ResponsiveModal: { template: '
', props: ['open'] }, + ResponsiveModal: { template: '
', props: ['open'] }, ResponsiveModalFooter: { template: '
' }, - ResponsiveModalHeader: { template: '
' }, - ResponsiveModalTitle: { template: '
' }, + ResponsiveModalHeader: { template: '
' }, + ResponsiveModalTitle: { template: '

' }, })); vi.mock('@heroicons/vue/24/solid', () => ({ + ArrowPathIcon: { template: '' }, ArrowRightIcon: { template: '' }, ArrowTopRightOnSquareIcon: { template: '' }, KeyIcon: { template: '' }, @@ -109,6 +110,18 @@ describe('ChangelogModal iframeSrc', () => { }, }); + it('keeps its title in the header and exposes a loading status', () => { + const wrapper = mount(ChangelogModal, { + props: { open: true, release: { version: '7.3.0' } }, + global: { plugins: [createTestI18n()] }, + }); + expect(wrapper.find('.responsive-modal > .responsive-modal-header h2').text()).toBe( + 'Unraid 7.3.0 release notes' + ); + expect(wrapper.find('[role="status"]').text()).toBe('Loading release notes'); + expect(wrapper.find('[role="status"] .brand-loading').exists()).toBe(true); + }); + beforeEach(() => { mockRenew.mockClear(); mockAvailableWithRenewal.value = false; diff --git a/web/__test__/components/CheckUpdateResponseModal.test.ts b/web/__test__/components/CheckUpdateResponseModal.test.ts index 0f768c07ac..4edeeb1c2e 100644 --- a/web/__test__/components/CheckUpdateResponseModal.test.ts +++ b/web/__test__/components/CheckUpdateResponseModal.test.ts @@ -1,11 +1,13 @@ import { nextTick, ref } from 'vue'; -import { 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 CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue'; import { createTestI18n, testTranslate } from '../utils/i18n'; +enableAutoUnmount(afterEach); + vi.mock('@unraid/ui', () => ({ BrandButton: { name: 'BrandButton', @@ -18,6 +20,7 @@ vi.mock('@unraid/ui', () => ({ emits: ['click'], template: '', }, + CardWrapper: { template: '
' }, BrandLoading: { template: '
' }, Button: { template: '' }, cn: (...classes: string[]) => classes.filter(Boolean).join(' '), @@ -31,7 +34,13 @@ vi.mock('@unraid/ui', () => ({ ResponsiveModalFooter: { template: '' }, ResponsiveModalHeader: { template: '
' }, ResponsiveModalTitle: { template: '
' }, - Switch: { name: 'Switch', props: ['modelValue'], template: '
' }, + Switch: { + name: 'Switch', + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', - }, -})); - -const mockAccountStore = { - updateOs: vi.fn(), -}; -vi.mock('~/store/account', () => ({ - useAccountStore: () => mockAccountStore, })); const mockRebootType = ref(''); @@ -35,6 +27,17 @@ vi.mock('~/store/server', () => ({ useServerStore: () => mockServerStore, })); +const mockUpdateOsStore = { + available: undefined as string | undefined, + availableWithRenewal: undefined as string | undefined, + localCheckForUpdate: vi.fn().mockResolvedValue(undefined), + setModalOpen: vi.fn(), + updateOsModalVisible: false, +}; +vi.mock('~/store/updateOs', () => ({ + useUpdateOsStore: () => mockUpdateOsStore, +})); + // Mock window.location Object.defineProperty(window, 'location', { value: { @@ -44,25 +47,31 @@ Object.defineProperty(window, 'location', { configurable: true, }); -vi.mock('~/helpers/urls', () => ({ - WEBGUI_TOOLS_UPDATE: '/Tools/Update', -})); - const UpdateOsStatusStub = { template: '
Status
', - props: ['showUpdateCheck', 'title', 'subtitle', 't'], + props: ['title', 'subtitle', 't'], }; const UpdateOsThirdPartyDriversStub = { template: '
Third Party
', props: ['t'], }; +const UpdateOsCheckUpdateResponseModalStub = { + template: '
Check Response
', + props: ['open', 'embedded'], +}; +const UpdateOsChangelogModalStub = { + template: '
Changelog
', +}; describe('UpdateOs.standalone.vue', () => { beforeEach(() => { vi.clearAllMocks(); mockRebootType.value = ''; mockSetRebootVersion.mockClear(); - mockAccountStore.updateOs.mockClear(); + mockUpdateOsStore.available = undefined; + mockUpdateOsStore.availableWithRenewal = undefined; + mockUpdateOsStore.localCheckForUpdate.mockResolvedValue(undefined); + mockUpdateOsStore.updateOsModalVisible = false; window.location.pathname = '/some/other/path'; }); @@ -99,7 +108,30 @@ describe('UpdateOs.standalone.vue', () => { }); describe('Initial Rendering and onBeforeMount Logic', () => { - it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => { + it.each(['', 'update', 'downgrade', 'thirdPartyDriversDownloading'])( + 'uses only the card on Tools with reboot=%s', + async (reboot) => { + window.location.pathname = '/Tools/Update'; + mockRebootType.value = reboot; + const wrapper = mount(UpdateOs, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], + stubs: { + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, + }, + }, + }); + await nextTick(); + expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="third-party-drivers"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="update-os-check-response"]').exists()).toBe(true); + } + ); + + it('checks for updates on the Tools page when no reboot is pending', async () => { window.location.pathname = '/Tools/Update'; mockRebootType.value = ''; @@ -107,21 +139,21 @@ describe('UpdateOs.standalone.vue', () => { global: { plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], stubs: { - // Rely on @unraid/ui mock for PageContainer & BrandButton UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); await nextTick(); - expect(mockAccountStore.updateOs).not.toHaveBeenCalled(); - expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(true); expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false); + expect(mockUpdateOsStore.localCheckForUpdate).toHaveBeenCalledTimes(1); }); - it('shows status and does not call updateOs when path does not match', async () => { + it('renders status without checking for updates on other pages', async () => { window.location.pathname = '/some/other/path'; mockRebootType.value = ''; @@ -129,21 +161,22 @@ describe('UpdateOs.standalone.vue', () => { global: { plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], stubs: { - // Rely on @unraid/ui mock for PageContainer & BrandLoading UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); await nextTick(); - expect(mockAccountStore.updateOs).not.toHaveBeenCalled(); - expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false); expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true); + expect(mockUpdateOsStore.localCheckForUpdate).not.toHaveBeenCalled(); + expect(mockUpdateOsStore.setModalOpen).not.toHaveBeenCalled(); }); - it('shows status and does not call updateOs when rebootType is not empty', async () => { + it('renders status without checking for updates while a reboot is pending', async () => { window.location.pathname = '/Tools/Update'; mockRebootType.value = 'downgrade'; @@ -151,39 +184,111 @@ describe('UpdateOs.standalone.vue', () => { global: { plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], stubs: { - // Rely on @unraid/ui mock for PageContainer & BrandLoading UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); await nextTick(); - expect(mockAccountStore.updateOs).not.toHaveBeenCalled(); - expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false); + expect(mockUpdateOsStore.localCheckForUpdate).not.toHaveBeenCalled(); + expect(mockUpdateOsStore.setModalOpen).not.toHaveBeenCalled(); }); - it('navigates to account update when the button is clicked', async () => { + it('opens the update modal when an update is already available', async () => { window.location.pathname = '/Tools/Update'; mockRebootType.value = ''; + mockUpdateOsStore.available = '6.12.5'; - const wrapper = mount(UpdateOs, { + mount(UpdateOs, { global: { plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); await nextTick(); - await wrapper.find('[data-testid="update-os-account-button"]').trigger('click'); + expect(mockUpdateOsStore.setModalOpen).toHaveBeenCalledWith(true); + expect(mockUpdateOsStore.localCheckForUpdate).not.toHaveBeenCalled(); + }); + + it('opens the known renewal release without another check', async () => { + window.location.pathname = '/Tools/Update'; + mockUpdateOsStore.availableWithRenewal = '7.3.0'; + mount(UpdateOs, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], + stubs: { + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, + }, + }, + }); + await flushPromises(); + expect(mockUpdateOsStore.setModalOpen).toHaveBeenCalledWith(true); + expect(mockUpdateOsStore.localCheckForUpdate).not.toHaveBeenCalled(); + }); + + it('handles a rejected initial check without an unhandled rejection', async () => { + window.location.pathname = '/Tools/Update'; + const error = new Error('update check unavailable'); + mockUpdateOsStore.localCheckForUpdate.mockRejectedValueOnce(error); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + mount(UpdateOs, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], + stubs: { + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, + }, + }, + }); + await flushPromises(); + expect(logged).toHaveBeenCalledWith(error); + } finally { + logged.mockRestore(); + } + }); + + it('embeds the update response on the Tools update page', async () => { + window.location.pathname = '/Tools/Update'; + mockRebootType.value = ''; + mockUpdateOsStore.updateOsModalVisible = true; + + const wrapper = mount(UpdateOs, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], + stubs: { + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, + }, + }, + }); + + await nextTick(); - expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true); + const checkResponse = wrapper.findComponent(UpdateOsCheckUpdateResponseModalStub); + expect(checkResponse.exists()).toBe(true); + expect(checkResponse.props('embedded')).not.toBe(false); + expect(wrapper.find('[data-testid="update-os-check-response"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="update-os-changelog"]').exists()).toBe(true); }); }); @@ -196,6 +301,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); @@ -216,6 +323,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); @@ -233,6 +342,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); diff --git a/web/__test__/components/UpdateOsStatus.test.ts b/web/__test__/components/UpdateOsStatus.test.ts new file mode 100644 index 0000000000..2c09c0ff35 --- /dev/null +++ b/web/__test__/components/UpdateOsStatus.test.ts @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; + +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import UpdateOsStatus from '~/components/UpdateOs/Status.vue'; +import { createTestI18n } from '../utils/i18n'; + +enableAutoUnmount(afterEach); + +vi.mock('@unraid/ui', () => ({ + Badge: { template: '' }, + BrandLoading: { template: '' }, + Button: { template: '' }, +})); + +const useServerStore = defineStore('server', { + state: () => ({ + rebootType: '', + rebootVersion: '', + osVersion: '7.2.0', + dateTimeFormat: {}, + regExp: 0, + regUpdatesExpired: false, + }), +}); +const useUpdateOsStore = defineStore('updateOs', { + state: () => ({ + available: undefined as string | undefined, + availableWithRenewal: undefined as string | undefined, + checkForUpdatesLoading: false, + }), + actions: { localCheckForUpdate: vi.fn(), setModalOpen: vi.fn(), cancelUpdate: vi.fn() }, +}); +const useUpdateOsActionsStore = defineStore('updateOsActions', { + state: () => ({ status: 'ready', ineligibleText: '', rebootTypeText: '' }), + actions: { viewReleaseNotes: vi.fn(), rebootServer: vi.fn() }, +}); +vi.mock('~/store/server', () => ({ useServerStore: () => useServerStore() })); +vi.mock('~/store/updateOs', () => ({ useUpdateOsStore: () => useUpdateOsStore() })); +vi.mock('~/store/updateOsActions', () => ({ useUpdateOsActionsStore: () => useUpdateOsActionsStore() })); +vi.mock('~/store/account', () => ({ + useAccountStore: () => ({ updateOs: vi.fn(), downgradeOs: vi.fn() }), +})); + +const mountStatus = (props = {}) => { + const pinia = createTestingPinia({ createSpy: vi.fn }); + const server = useServerStore(pinia); + server.rebootType = ''; + server.osVersion = '7.2.0'; + const update = useUpdateOsStore(pinia); + const actions = useUpdateOsActionsStore(pinia); + actions.status = 'ready'; + const wrapper = mount(UpdateOsStatus, { + props, + global: { plugins: [pinia, createTestI18n()] }, + }); + return { wrapper, server, update, actions }; +}; + +describe('Update OS status actions', () => { + beforeEach(() => vi.clearAllMocks()); + + it('checks for updates when no release is available', async () => { + const { wrapper, update } = mountStatus(); + const check = wrapper.findAll('button').filter((button) => button.text() === 'Check for Update'); + expect(check).toHaveLength(1); + await check[0].trigger('click'); + expect(update.localCheckForUpdate).toHaveBeenCalledTimes(1); + }); + + it.each(['available', 'availableWithRenewal'] as const)( + 'keeps refresh beside the %s action', + async (field) => { + const { wrapper, update } = mountStatus(); + update[field] = '7.3.0'; + await wrapper.vm.$nextTick(); + const check = wrapper.findAll('button').find((button) => button.text() === 'Check for Update')!; + const release = wrapper.findAll('button').find((button) => button.text().includes('7.3.0'))!; + expect(check.exists()).toBe(true); + expect(release.exists()).toBe(true); + await release.trigger('click'); + expect(update.setModalOpen).toHaveBeenCalledWith(true); + await check.trigger('click'); + expect(update.localCheckForUpdate).toHaveBeenCalledTimes(1); + } + ); + + it.each(['action', 'local'])( + 'disables refresh and release actions during a %s check', + async (source) => { + const { wrapper, update, actions } = mountStatus(); + update.available = '7.3.0'; + if (source === 'action') actions.status = 'checking'; + else update.checkForUpdatesLoading = true; + await wrapper.vm.$nextTick(); + const buttons = wrapper + .findAll('button') + .filter((button) => button.text() === 'Check for Update' || button.text().includes('7.3.0')); + expect(buttons).toHaveLength(2); + for (const button of buttons) { + expect(button.attributes('disabled')).toBeDefined(); + await button.trigger('click'); + } + expect(update.localCheckForUpdate).not.toHaveBeenCalled(); + expect(update.setModalOpen).not.toHaveBeenCalled(); + } + ); + + it.each(['update', 'downgrade'] as const)( + 'hides refresh while a %s reboot is pending', + async (rebootType) => { + const { wrapper, update, server } = mountStatus(); + update.available = '7.3.0'; + server.rebootType = rebootType; + await wrapper.vm.$nextTick(); + expect(wrapper.findAll('button').some((button) => button.text() === 'Check for Update')).toBe( + false + ); + } + ); + + it('hides refresh on the external downgrade flow', async () => { + const { wrapper, update } = mountStatus({ showExternalDowngrade: true }); + update.available = '7.3.0'; + await wrapper.vm.$nextTick(); + expect(wrapper.findAll('button').some((button) => button.text() === 'Check for Update')).toBe(false); + }); +}); diff --git a/web/__test__/store/updateOs.test.ts b/web/__test__/store/updateOs.test.ts index 80134313c4..195f67a0af 100644 --- a/web/__test__/store/updateOs.test.ts +++ b/web/__test__/store/updateOs.test.ts @@ -175,6 +175,10 @@ describe('UpdateOs Store', () => { ); expect(WebguiCheckForUpdate).toHaveBeenCalled(); + expect(store.checkForUpdatesLoading).toBe(false); + expect(store.checkForUpdatesFailed).toBe(true); + await store.localCheckForUpdate(); + expect(store.checkForUpdatesFailed).toBe(false); }); it('should successfully cancel an update', async () => { diff --git a/web/__test__/store/updateOs.transport.test.ts b/web/__test__/store/updateOs.transport.test.ts new file mode 100644 index 0000000000..44d7d7715c --- /dev/null +++ b/web/__test__/store/updateOs.transport.test.ts @@ -0,0 +1,108 @@ +import { createPinia, setActivePinia } from 'pinia'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUpdateOsStore } from '~/store/updateOs'; + +const { setUpdateOsResponse } = vi.hoisted(() => ({ setUpdateOsResponse: vi.fn() })); + +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: () => ({ send: vi.fn() }), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: () => ({ + regExp: '', + regUpdatesExpired: false, + updateOsIgnoredReleases: [], + updateOsResponse: { version: '7.3.4', isNewer: true }, + setUpdateOsResponse, + }), +})); + +const updateResponse = (isNewer = true) => ({ version: '7.3.4', isNewer, isEligible: true }); +const jsonResponse = (data: unknown) => + new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('OS update check transport', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('preserves the last result on network failure and recovers on retry', async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(jsonResponse(updateResponse())); + vi.stubGlobal('fetch', fetch); + const store = useUpdateOsStore(); + + await expect(store.localCheckForUpdate()).rejects.toThrow(); + expect(store.checkForUpdatesFailed).toBe(true); + expect(store.checkForUpdatesLoading).toBe(false); + expect(store.available).toBe('7.3.4'); + expect(setUpdateOsResponse).not.toHaveBeenCalled(); + + await store.localCheckForUpdate(); + expect(store.checkForUpdatesFailed).toBe(false); + expect(store.checkForUpdatesLoading).toBe(false); + expect(setUpdateOsResponse).toHaveBeenCalledWith(updateResponse()); + }); + + it.each([ + undefined, + null, + 42, + {}, + { error: 'Upstream unavailable' }, + { version: '7.3.4' }, + { version: '', isNewer: false }, + { version: '7.3.4', isNewer: 'false' }, + ])('rejects an invalid native result: %j', async (result) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(result))); + const store = useUpdateOsStore(); + + await expect(store.localCheckForUpdate()).rejects.toThrow(); + expect(store.checkForUpdatesFailed).toBe(true); + expect(store.checkForUpdatesLoading).toBe(false); + expect(setUpdateOsResponse).not.toHaveBeenCalled(); + }); + + it('rejects an HTTP failure instead of clearing the last release', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'Unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + const store = useUpdateOsStore(); + + await expect(store.localCheckForUpdate()).rejects.toThrow(); + expect(store.checkForUpdatesFailed).toBe(true); + expect(store.checkForUpdatesLoading).toBe(false); + expect(setUpdateOsResponse).not.toHaveBeenCalled(); + }); + + it('accepts a successful native result with no newer release', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(updateResponse(false)))); + const store = useUpdateOsStore(); + + await store.localCheckForUpdate(); + expect(store.checkForUpdatesFailed).toBe(false); + expect(setUpdateOsResponse).toHaveBeenCalledWith(updateResponse(false)); + }); +}); diff --git a/web/src/components/Modals.standalone.vue b/web/src/components/Modals.standalone.vue index 5d13abe84f..9d6b70cf5f 100644 --- a/web/src/components/Modals.standalone.vue +++ b/web/src/components/Modals.standalone.vue @@ -1,6 +1,8 @@ diff --git a/web/src/components/UpdateOs/ChangelogModal.vue b/web/src/components/UpdateOs/ChangelogModal.vue index daef6f203b..645b51b5e2 100644 --- a/web/src/components/UpdateOs/ChangelogModal.vue +++ b/web/src/components/UpdateOs/ChangelogModal.vue @@ -143,18 +143,19 @@ const showRawChangelog = computed(() => { :open="modalVisible" sheet-side="bottom" sheet-padding="none" - :dialog-class="'max-w-[80rem] p-0'" + :dialog-class="'max-h-[90dvh] max-w-[80rem] p-0'" + sheet-class="h-full gap-6" :show-close-button="true" @update:open="(value: boolean) => !value && handleClose()" > - - + + {{ t('updateOs.changelogModal.unraidOsChangelog', [currentRelease.version]) }} -
-
+
+