Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions web/__test__/components/UpdateOs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { createTestI18n } from '../utils/i18n';

vi.mock('@unraid/ui', () => ({
PageContainer: { template: '<div><slot /></div>' },
BrandLoading: { template: '<div data-testid="brand-loading-mock">Loading...</div>' },
BrandButton: {
template: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
},
}));

const mockAccountStore = {
Expand Down Expand Up @@ -97,15 +99,15 @@ describe('UpdateOs.standalone.vue', () => {
});

describe('Initial Rendering and onBeforeMount Logic', () => {
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';

const wrapper = mount(UpdateOs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
// Rely on @unraid/ui mock for PageContainer & BrandLoading
// Rely on @unraid/ui mock for PageContainer & BrandButton
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
Expand All @@ -114,17 +116,9 @@ describe('UpdateOs.standalone.vue', () => {

await nextTick();

// When path matches and rebootType is empty, updateOs should be called
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
// The loader should be visible when showLoader is true
const loaderWrapper = wrapper.find('[data-testid="brand-loading-mock"]').element.parentElement;
expect(loaderWrapper?.style.display).not.toBe('none');
// The status should be hidden when showLoader is true
const statusWrapper = wrapper.find('[data-testid="update-os-status"]').element.parentElement;
expect(statusWrapper?.style.display).toBe('none');
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);
});

it('shows status and does not call updateOs when path does not match', async () => {
Expand All @@ -145,8 +139,7 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();

expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});

Expand All @@ -168,10 +161,30 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();

expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});

it('navigates to account update when the button is clicked', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';

const wrapper = mount(UpdateOs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
},
});

await nextTick();

await wrapper.find('[data-testid="update-os-account-button"]').trigger('click');

expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
});
});

describe('Rendering based on rebootType', () => {
Expand Down
60 changes: 59 additions & 1 deletion web/__test__/store/updateOs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,41 @@

import { createPinia, setActivePinia } from 'pinia';

import { WEBGUI_REDIRECT } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useUpdateOsStore } from '~/store/updateOs';

const mockSend = vi.fn();

vi.mock('@unraid/shared-callbacks', () => ({
useCallback: vi.fn(() => ({
send: vi.fn(),
send: mockSend,
watcher: vi.fn(),
})),
}));

vi.mock('~/composables/preventClose', () => ({
addPreventClose: vi.fn(),
removePreventClose: vi.fn(),
}));

vi.mock('~/store/account', () => ({
useAccountStore: () => ({
accountActionStatus: 'ready',
}),
}));

vi.mock('~/store/installKey', () => ({
useInstallKeyStore: () => ({
keyInstallStatus: 'ready',
}),
}));

vi.mock('~/store/updateOsActions', () => ({
useUpdateOsActionsStore: () => ({}),
}));

vi.mock('~/composables/services/webgui', () => {
return {
WebguiCheckForUpdate: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -104,6 +128,40 @@ describe('UpdateOs Store', () => {
expect(store.updateOsModalVisible).toBe(false);
});

it('should send update install through redirect.htm', () => {
const originalLocation = window.location;

Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
origin: 'https://littlebox.tail45affd.ts.net',
href: 'https://littlebox.tail45affd.ts.net/Plugins',
},
});

store.fetchAndConfirmInstall('test-sha256');

const expectedUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();

expect(mockSend).toHaveBeenCalledWith(
expectedUrl,
[
{
sha256: 'test-sha256',
type: 'updateOs',
},
],
undefined,
'forUpc'
);

Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});

it('should handle errors when checking for updates', async () => {
const { WebguiCheckForUpdate } = await import('~/composables/services/webgui');

Expand Down
38 changes: 28 additions & 10 deletions web/src/components/UpdateOs.standalone.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { computed, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';

import { BrandLoading, PageContainer } from '@unraid/ui';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton, PageContainer } from '@unraid/ui';
import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls';

import UpdateOsStatus from '~/components/UpdateOs/Status.vue';
Expand Down Expand Up @@ -47,25 +48,42 @@ const subtitle = computed(() => {
return '';
});

/** when we're not prompting for reboot /Tools/Update will automatically send the user to account.unraid.net/server/update-os */
const showLoader = computed(
() => window.location.pathname === WEBGUI_TOOLS_UPDATE && rebootType.value === ''
// 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(() => {
if (showLoader.value) {
accountStore.updateOs(true);
}
serverStore.setRebootVersion(props.rebootVersion);
});
</script>

<template>
<PageContainer>
<div v-show="showLoader">
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
<div
v-if="showRedirectPrompt"
class="mx-auto flex max-w-[720px] flex-col items-center gap-4 py-8 text-center"
>
<h1 class="text-2xl font-semibold">{{ t('updateOs.updateUnraidOs') }}</h1>
<p class="text-base leading-relaxed opacity-75">
{{ t('updateOs.update.receiveTheLatestAndGreatestFor') }}
</p>
<BrandButton
data-testid="update-os-account-button"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openAccountUpdate"
>
{{ t('updateOs.update.viewAvailableUpdates') }}
</BrandButton>
</div>
<div v-show="!showLoader">
<div v-else>
<UpdateOsStatus
:show-update-check="true"
:title="t('updateOs.updateUnraidOs')"
Expand Down
2 changes: 2 additions & 0 deletions web/src/helpers/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const UNRAID_NET_SUPPORT = new URL('/support', UNRAID_NET);
const WEBGUI_GRAPHQL = '/graphql';
const WEBGUI_SETTINGS_MANAGMENT_ACCESS = '/Settings/ManagementAccess';
const WEBGUI_CONNECT_SETTINGS = `${WEBGUI_SETTINGS_MANAGMENT_ACCESS}#UnraidNetSettings`;
const WEBGUI_REDIRECT = '/redirect.htm';
const WEBGUI_TOOLS_DOWNGRADE = '/Tools/Downgrade';
const WEBGUI_TOOLS_REGISTRATION = '/Tools/Registration';
const WEBGUI_TOOLS_UPDATE = '/Tools/Update';
Expand Down Expand Up @@ -66,6 +67,7 @@ export {
DOCS_REGISTRATION_LICENSING,
DOCS_REGISTRATION_REPLACE_KEY,
WEBGUI_CONNECT_SETTINGS,
WEBGUI_REDIRECT,
WEBGUI_GRAPHQL,
WEBGUI_SETTINGS_MANAGMENT_ACCESS,
WEBGUI_TOOLS_DOWNGRADE,
Expand Down
4 changes: 3 additions & 1 deletion web/src/store/updateOs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';

import { WEBGUI_REDIRECT } from '~/helpers/urls';
import dayjs, { extend } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
Expand Down Expand Up @@ -71,8 +72,9 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
// fetchAndConfirmInstall logic
const callbackStore = useCallbackActionsStore();
const fetchAndConfirmInstall = (sha256: string) => {
const redirectUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
callbackStore.send(
window.location.href,
redirectUrl,
[
{
sha256,
Expand Down
Loading