Skip to content
Draft
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
108 changes: 76 additions & 32 deletions web/__test__/components/UpdateOs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,6 @@ import { createTestI18n } from '../utils/i18n';

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

const mockAccountStore = {
updateOs: vi.fn(),
};
vi.mock('~/store/account', () => ({
useAccountStore: () => mockAccountStore,
}));

const mockRebootType = ref('');
Expand All @@ -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: {
Expand All @@ -44,10 +45,6 @@ Object.defineProperty(window, 'location', {
configurable: true,
});

vi.mock('~/helpers/urls', () => ({
WEBGUI_TOOLS_UPDATE: '/Tools/Update',
}));

const UpdateOsStatusStub = {
template: '<div data-testid="update-os-status">Status</div>',
props: ['showUpdateCheck', 'title', 'subtitle', 't'],
Expand All @@ -56,13 +53,23 @@ const UpdateOsThirdPartyDriversStub = {
template: '<div data-testid="third-party-drivers">Third Party</div>',
props: ['t'],
};
const UpdateOsCheckUpdateResponseModalStub = {
template: '<div v-if="open" data-testid="update-os-check-response">Check Response</div>',
props: ['open', 'embedded'],
};
const UpdateOsChangelogModalStub = {
template: '<div data-testid="update-os-changelog">Changelog</div>',
};

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';
});

Expand Down Expand Up @@ -99,91 +106,122 @@ 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 = '';

const wrapper = mount(UpdateOs, {
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);
Comment on lines +127 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid asserting a stub-only prop that the real component doesn’t define.

This assertion can pass even if production behavior is wrong, because it only verifies data passed into a test stub, not UpdateOsStatus runtime behavior.

Suggested change
-      expect(wrapper.findComponent(UpdateOsStatusStub).props('showUpdateCheck')).toBe(true);
+      // Assert rendered behavior instead of stub-only prop plumbing.
+      expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);

As per coding guidelines, “Test what the code does, not implementation details like exact error message wording.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
expect(wrapper.findComponent(UpdateOsStatusStub).props('showUpdateCheck')).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
// Assert rendered behavior instead of stub-only prop plumbing.
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/__test__/components/UpdateOs.test.ts` around lines 103 - 104, The test is
asserting a prop on UpdateOsStatusStub which only verifies the test stub, not
the real UpdateOsStatus behavior; change the test to either mount the real
UpdateOsStatus component so you can assert its runtime behavior (e.g., check
emitted events or rendered DOM from UpdateOsStatus) or remove the stub-prop
assertion and instead assert observable output — for example, keep
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true) and
replace
expect(wrapper.findComponent(UpdateOsStatusStub).props('showUpdateCheck')).toBe(true)
with an assertion against the actual rendered DOM or emitted event from
UpdateOsStatus (or mount UpdateOs and assert its prop/state) using
wrapper.findComponent(UpdateOsStatus) or inspecting its emitted events.

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 = '';

const wrapper = mount(UpdateOs, {
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';

const wrapper = mount(UpdateOs, {
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);
});
});

Expand All @@ -196,6 +234,8 @@ describe('UpdateOs.standalone.vue', () => {
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub,
UpdateOsChangelogModal: UpdateOsChangelogModalStub,
},
},
});
Expand All @@ -216,6 +256,8 @@ describe('UpdateOs.standalone.vue', () => {
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub,
UpdateOsChangelogModal: UpdateOsChangelogModalStub,
},
},
});
Expand All @@ -233,6 +275,8 @@ describe('UpdateOs.standalone.vue', () => {
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
UpdateOsCheckUpdateResponseModal: UpdateOsCheckUpdateResponseModalStub,
UpdateOsChangelogModal: UpdateOsChangelogModalStub,
},
},
});
Expand Down
83 changes: 42 additions & 41 deletions web/src/components/UpdateOs.standalone.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -37,9 +38,14 @@ const props = withDefaults(defineProps<Props>(), {
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') {
Expand All @@ -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);
});
});
</script>

<template>
<PageContainer>
<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-else>
<UpdateOsStatus
:show-update-check="true"
:title="t('updateOs.updateUnraidOs')"
:subtitle="subtitle"
/>
<UpdateOsThirdPartyDrivers v-if="rebootType === 'thirdPartyDriversDownloading'" />
</div>
<UpdateOsStatus
:show-update-check="true"
:title="t('updateOs.updateUnraidOs')"
:subtitle="subtitle"
/>
<UpdateOsCheckUpdateResponseModal
v-if="isToolsUpdatePage && rebootType === ''"
:open="updateOsModalVisible"
embedded
/>
<UpdateOsChangelogModal v-if="isToolsUpdatePage" />
<UpdateOsThirdPartyDrivers v-if="rebootType === 'thirdPartyDriversDownloading'" />
Comment on lines +83 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm UpdateOsStatus declares and uses a showUpdateCheck prop
fd -i 'Status.vue' -p 'UpdateOs'
rg -nP -C3 '(showUpdateCheck|show-update-check)' -g '*Status.vue'
# Inspect the defineProps block for the component
ast-grep --pattern 'defineProps<$_>()'

Repository: unraid/api

Length of output: 150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find where UpdateOsStatus is defined/implemented
rg -n "UpdateOsStatus" web/src/components/UpdateOs.standalone.vue web/src/components -S

# 2) Locate any Status component that corresponds to UpdateOsStatus
fd -e vue -a -t f . web/src/components -S | head -n 200

# 3) Search for the prop name variants across the repo
rg -n "(showUpdateCheck|show-update-check)" web/src/components -S
rg -n "(showUpdateCheck|show-update-check)" web/src -S

# 4) Find the component file that likely declares showUpdateCheck
#    If UpdateOsStatus is imported from another file, inspect that.
imports=$(rg -n "import\s+.*UpdateOsStatus.*from" web/src/components/UpdateOs.standalone.vue -S || true)
echo "$imports"

# 5) If we can locate the file, inspect defineProps/props usage for showUpdateCheck
#    (we'll re-run with the discovered file if needed)

Repository: unraid/api

Length of output: 715


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="web/src/components/UpdateOs/Status.vue"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "== wc -l =="
wc -l "$FILE"

echo "== occurrences of showUpdateCheck/show-update-check =="
rg -n "(showUpdateCheck|show-update-check)" "$FILE" -S || true

echo "== defineProps/props block context =="
# Show likely prop declaration area (covers both defineProps and classic props:)
rg -n -C5 "(defineProps|props\s*:)" "$FILE" -S || true

echo "== template usage around any showUpdateCheck reference =="
# If occurrences exist, show a bit more context for each match
rg -n -C8 "(showUpdateCheck|show-update-check)" "$FILE" -S || true

Repository: unraid/api

Length of output: 566


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="web/src/components/UpdateOs/Status.vue"
rg -n "(\$attrs|useAttrs|attrs\s*\.|attrs\s*\[|inheritAttrs)" "$FILE" -S || true
rg -n "show-update-check|showUpdateCheck" "$FILE" -S || true

Repository: unraid/api

Length of output: 36


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "(showUpdateCheck|show-update-check)" web/src/components/UpdateOs -S || true
rg -n "(showUpdateCheck|show-update-check)" web/src -S || true

Repository: unraid/api

Length of output: 132


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="web/src/components/UpdateOs/Status.vue"

echo "== occurrences: showRefreshButton =="
rg -n "showRefreshButton" "$FILE" -S || true

echo "== occurrences: updateCheck / update-check =="
rg -n "(updateCheck|update-check|checkFor|check for|updates)" "$FILE" -S || true

echo "== template region around any refresh-related UI =="
rg -n "refresh" "$FILE" -S || true

# show a small window around the first few refresh/update occurrences (if any)
FIRST=$(rg -n "(refresh|update|updates|check)" "$FILE" -S | head -n 1 | cut -d: -f1 || true)
if [ -n "${FIRST:-}" ]; then
  START=$((FIRST-15))
  END=$((FIRST+60))
  [ $START -lt 1 ] && START=1
  echo "== showing lines $START-$END around first match line $FIRST =="
  sed -n "${START},${END}p" "$FILE" | nl -ba -w2 -s':'
fi

Repository: unraid/api

Length of output: 771


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="web/src/components/UpdateOs/Status.vue"
echo "=== Props area (lines 1-60) ==="
sed -n '1,80p' "$FILE"

echo
echo "=== showRefreshButton computed area (lines 70-130) ==="
sed -n '70,140p' "$FILE"

echo
echo "=== Template refresh/update UI (lines 200-270) ==="
sed -n '200,280p' "$FILE"

Repository: unraid/api

Length of output: 7665


show-update-check is not consumed by UpdateOsStatus (no-op)

<UpdateOsStatus
  :show-update-check="true"
  :title="t('updateOs.updateUnraidOs')"
  :subtitle="subtitle"
/>

web/src/components/UpdateOs/Status.vue does not declare showUpdateCheck in its props, and the “check for update” button is controlled by v-if="showRefreshButton" where showRefreshButton is computed from updateAvailable, showRebootButton, and props.showExternalDowngrade—not showUpdateCheck. Remove the prop from the parent or wire showUpdateCheck into the button visibility logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/UpdateOs.standalone.vue` around lines 76 - 81, The parent
passes a no-op prop show-update-check to the UpdateOsStatus component; either
remove that prop from the caller or wire it into the child’s visibility logic.
To fix: in UpdateOsStatus (UpdateOs/Status.vue) add a prop showUpdateCheck
(Boolean, default false) and include it in the computed showRefreshButton
(alongside updateAvailable, showRebootButton, and props.showExternalDowngrade)
so the “check for update” button respects showUpdateCheck, or alternatively
remove :show-update-check="true" from the UpdateOs.standalone.vue usage to avoid
passing an unused prop (change whichever approach matches intent).

</PageContainer>
</template>
Loading
Loading