From 9d7219d61c5782cd8405acf6975e047be65cdcde Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 1 Jun 2026 12:45:32 -0400 Subject: [PATCH] fix: allow internal OS update checks from Tools page --- web/__test__/components/UpdateOs.test.ts | 108 +++++++++---- web/src/components/UpdateOs.standalone.vue | 83 +++++----- .../UpdateOs/CheckUpdateResponseModal.vue | 149 ++++++++++++++++++ web/src/components/UpdateOs/Status.vue | 23 ++- 4 files changed, 287 insertions(+), 76 deletions(-) diff --git a/web/__test__/components/UpdateOs.test.ts b/web/__test__/components/UpdateOs.test.ts index 83e15aa4ce..0c05efcf1b 100644 --- a/web/__test__/components/UpdateOs.test.ts +++ b/web/__test__/components/UpdateOs.test.ts @@ -13,16 +13,6 @@ import { createTestI18n } from '../utils/i18n'; vi.mock('@unraid/ui', () => ({ PageContainer: { template: '
' }, - BrandButton: { - template: '', - }, -})); - -const mockAccountStore = { - updateOs: vi.fn(), -}; -vi.mock('~/store/account', () => ({ - useAccountStore: () => mockAccountStore, })); const mockRebootType = ref(''); @@ -35,6 +25,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,10 +45,6 @@ Object.defineProperty(window, 'location', { configurable: true, }); -vi.mock('~/helpers/urls', () => ({ - WEBGUI_TOOLS_UPDATE: '/Tools/Update', -})); - const UpdateOsStatusStub = { template: '
Status
', props: ['showUpdateCheck', 'title', 'subtitle', 't'], @@ -56,13 +53,23 @@ 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 +106,7 @@ 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('shows the internal update status when path matches and rebootType is empty', async () => { window.location.pathname = '/Tools/Update'; mockRebootType.value = ''; @@ -107,21 +114,22 @@ 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(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true); + expect(wrapper.findComponent(UpdateOsStatusStub).props('showUpdateCheck')).toBe(true); + expect(mockUpdateOsStore.localCheckForUpdate).toHaveBeenCalledTimes(1); }); - it('shows status and does not call updateOs when path does not match', async () => { + it('shows status when path does not match', async () => { window.location.pathname = '/some/other/path'; mockRebootType.value = ''; @@ -129,21 +137,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('shows status when rebootType is not empty', async () => { window.location.pathname = '/Tools/Update'; mockRebootType.value = 'downgrade'; @@ -151,39 +160,68 @@ 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('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('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 +234,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); @@ -216,6 +256,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); @@ -233,6 +275,8 @@ describe('UpdateOs.standalone.vue', () => { stubs: { UpdateOsStatus: UpdateOsStatusStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub, + UpdateOsChangelogModal: UpdateOsChangelogModalStub, }, }, }); diff --git a/web/src/components/UpdateOs.standalone.vue b/web/src/components/UpdateOs.standalone.vue index 7fd17ab354..72988946bc 100644 --- a/web/src/components/UpdateOs.standalone.vue +++ b/web/src/components/UpdateOs.standalone.vue @@ -15,18 +15,19 @@ else echo "Third party plugins found - PLEASE CHECK YOUR UNRAID NOTIFICATIONS AND WAIT FOR THE MESSAGE THAT IT IS SAFE TO REBOOT!" fi */ -import { computed, onBeforeMount } from 'vue'; +import { computed, onBeforeMount, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { storeToRefs } from 'pinia'; -import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'; -import { BrandButton, PageContainer } from '@unraid/ui'; +import { PageContainer } from '@unraid/ui'; import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls'; +import UpdateOsChangelogModal from '~/components/UpdateOs/ChangelogModal.vue'; +import UpdateOsCheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue'; import UpdateOsStatus from '~/components/UpdateOs/Status.vue'; import UpdateOsThirdPartyDrivers from '~/components/UpdateOs/ThirdPartyDrivers.vue'; -import { useAccountStore } from '~/store/account'; import { useServerStore } from '~/store/server'; +import { useUpdateOsStore } from '~/store/updateOs'; const { t } = useI18n(); @@ -37,9 +38,14 @@ const props = withDefaults(defineProps(), { rebootVersion: '', }); -const accountStore = useAccountStore(); const serverStore = useServerStore(); +const updateOsStore = useUpdateOsStore(); const { rebootType } = storeToRefs(serverStore); +const updateOsModalVisible = computed(() => updateOsStore.updateOsModalVisible); + +const isToolsUpdatePage = computed( + () => typeof window !== 'undefined' && window.location.pathname === WEBGUI_TOOLS_UPDATE +); const subtitle = computed(() => { if (rebootType.value === 'downgrade') { @@ -48,48 +54,43 @@ const subtitle = computed(() => { return ''; }); -// Show a prompt to continue in the Account app when no reboot is pending. -const showRedirectPrompt = computed( - () => - typeof window !== 'undefined' && - window.location.pathname === WEBGUI_TOOLS_UPDATE && - rebootType.value === '' -); - -const openAccountUpdate = () => { - accountStore.updateOs(true); -}; - onBeforeMount(() => { serverStore.setRebootVersion(props.rebootVersion); }); + +onMounted(() => { + if ( + typeof window === 'undefined' || + window.location.pathname !== WEBGUI_TOOLS_UPDATE || + rebootType.value !== '' + ) { + return; + } + + if (updateOsStore.available || updateOsStore.availableWithRenewal) { + updateOsStore.setModalOpen(true); + return; + } + + void updateOsStore.localCheckForUpdate().catch((error: unknown) => { + console.error(error); + }); +}); diff --git a/web/src/components/UpdateOs/CheckUpdateResponseModal.vue b/web/src/components/UpdateOs/CheckUpdateResponseModal.vue index c4719571d6..51496da389 100644 --- a/web/src/components/UpdateOs/CheckUpdateResponseModal.vue +++ b/web/src/components/UpdateOs/CheckUpdateResponseModal.vue @@ -16,6 +16,7 @@ import { BrandButton, BrandLoading, Button, + CardWrapper, cn, DialogDescription, Label, @@ -40,10 +41,12 @@ import { useServerStore } from '~/store/server'; import { useUpdateOsStore } from '~/store/updateOs'; export interface Props { + embedded?: boolean; open?: boolean; } withDefaults(defineProps(), { + embedded: false, open: false, }); const { t } = useI18n(); @@ -281,7 +284,153 @@ const modalWidth = computed(() => {