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
93 changes: 93 additions & 0 deletions web/__test__/components/HeaderServerStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { VueWrapper } from '@vue/test-utils';

import ServerStatus from '~/components/UserProfile/ServerStatus.vue';
import UptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
import { useServerStore } from '~/store/server';
import { createTestI18n } from '../utils/i18n';

vi.mock('~/store/server', async () => {
const { defineStore } = await import('pinia');
return {
useServerStore: defineStore('server', {
state: () => ({
state: 'LIFETIME',
uptime: Date.now() - 9 * 86400000,
expireTime: Date.now() + 14 * 86400000,
dateTimeFormat: { date: '%c', time: '%R' },
}),
}),
};
});

vi.mock('@unraid/ui', () => {
const slot = { template: '<div><slot /></div>' };
return {
cn: (...values: string[]) => values.filter(Boolean).join(' '),
Tooltip: slot,
TooltipContent: slot,
TooltipProvider: slot,
TooltipTrigger: slot,
};
});

describe('header uptime and expiry states', () => {
let wrapper: VueWrapper | undefined;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-10T12:00:00Z'));
setActivePinia(createPinia());
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use createTestingPinia for this component test

This Vue component test initializes a plain createPinia() while replacing the server store with a module mock. Component tests in this repository are required to use createTestingPinia() for mocked stores; bypassing that harness can make action stubbing and initialization differ from the rest of the component suite. Configure initialState and stubActions on createTestingPinia() instead.

AGENTS.md reference: AGENTS.md:L138-L138

Useful? React with 👍 / 👎.

afterEach(() => {
wrapper?.unmount();
vi.clearAllTimers();
vi.useRealTimers();
});

it('refreshes the compact timestamp when a running server changes to a trial or expired trial', async () => {
wrapper = mount(ServerStatus, {
global: { plugins: [createTestI18n()], stubs: { UpcServerState: true } },
});
const server = useServerStore();
expect(wrapper.text()).toContain('Uptime 9 days');

server.state = 'TRIAL';
await nextTick();
expect(wrapper.text()).toContain('Expires in 14 days');
expect(wrapper.text()).not.toContain('Expires in 9 days');

server.expireTime = Date.now() + 17 * 86400000;
await nextTick();
expect(wrapper.text()).toContain('Expires in 17 days');

server.state = 'ENOCONN';
await nextTick();
expect(wrapper.text()).toContain('Expires in 17 days');

server.state = 'EEXPIRED';
server.expireTime = Date.now() - 2 * 86400000;
await nextTick();
expect(wrapper.text()).toContain('Expired 2 days');
});

it('keeps compact expiry to its largest unit while preserving the full expiry display', () => {
const server = useServerStore();
server.state = 'TRIAL';
server.expireTime = Date.now() + 14 * 86400000 + 2 * 3600000 + 3 * 60000;
wrapper = mount(UptimeExpire, {
props: { shortText: true },
global: { plugins: [createTestI18n()] },
});
expect(wrapper.text()).toBe('Expires in 14 days');
wrapper.unmount();
wrapper = mount(UptimeExpire, { global: { plugins: [createTestI18n()] } });
expect(wrapper.text()).toContain('14 days 2 hours 3 minutes');
});
});
61 changes: 45 additions & 16 deletions web/__test__/components/HeaderThemes.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ref } from 'vue';
import { readFileSync } from 'node:fs';
import { URL as NodeURL } from 'node:url';

import { nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { provideApolloClient } from '@vue/apollo-composable';
import { mount } from '@vue/test-utils';
Expand Down Expand Up @@ -88,7 +91,7 @@ const THEMES = [
const layoutSignature = (wrapper: VueWrapper) => {
const root = wrapper.get('#UnraidHeader');
const gradient = root.find('.unraid-banner-gradient-layer');
const logoSvg = root.find('.uh-logo-block a svg');
const logoSvg = root.find('.uh-logo-block a :is(svg, img)');
return {
gradientLayer: gradient.exists() ? [...gradient.classes()].sort().join(' ') : null,
logoSvg: logoSvg.exists() ? [...logoSvg.classes()].sort().join(' ') : null,
Expand Down Expand Up @@ -185,6 +188,19 @@ describe('Header.standalone.vue theme layout', () => {
}
);

it('embeds the original WebGUI logo asset without reconstructing its paths', () => {
const image = mountWithTheme('white').get('.uh-logo-block img');
const original = readFileSync(
new NodeURL('../../../api/dev/webGui/images/UN-logotype-gradient.svg', import.meta.url),
'utf8'
);
expect(decodeURIComponent(image.attributes('src')!.replace('data:image/svg+xml,', ''))).toBe(
original
);
expect(image.attributes('width')).toBe('222.36');
expect(image.attributes('height')).toBe('39.04');
});

it.each(['gradient', 'theme'] as const)('renders the %s header logo style', (logoStyle) => {
expect(layoutSignature(mountWithTheme('white', { logoStyle })).logoSvg).toMatchSnapshot();
});
Expand All @@ -199,27 +215,41 @@ describe('Header.standalone.vue theme layout', () => {
it.each(THEMES.map((theme) => [theme.name, theme] as const))(
'keeps the logo a fixed size on the %s theme so the boot placeholder cannot resize on mount',
(_name, theme) => {
const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a svg');
const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a :is(svg, img)');
expect(logoSvg.classes()).toContain('shrink-0');
expect(logoSvg.classes()).not.toContain('max-w-full');
expect(logoSvg.classes()).toContain('w-[14rem]');
expect(logoSvg.classes()).toContain('xs:w-[16rem]');
}
);

/**
* Regression guard: the banner gradient must stay within the legacy header's
* `min(30%, 320px)` edge width. A bare `left-[55%]` covered the right 45%
* (~576px at 1280px, ~1.8x legacy) and visibly obscured users' banner images.
*/
it('scopes the banner gradient to the legacy edge width', () => {
const gradient = mountWithTheme('white').get('.unraid-banner-gradient-layer');
expect(gradient.classes()).toContain('w-[min(30%,320px)]');
expect(gradient.classes()).toContain('right-0');
expect(gradient.classes()).not.toContain('left-[55%]');
it('enables the header fade only while both banner and fade are enabled', async () => {
const style = document.documentElement.style;
const previousGradient = style.getPropertyValue('--banner-gradient');
style.setProperty('--banner-gradient', 'linear-gradient(90deg, transparent, #1c1b1b)');
try {
const wrapper = mountWithTheme('white');
const themeStore = useThemeStore();
expect(wrapper.classes()).toContain('uh-banner-gradient-enabled');

themeStore.setTheme({ bannerGradient: false });
await nextTick();
expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');

themeStore.setTheme({ bannerGradient: true });
await nextTick();
expect(wrapper.classes()).toContain('uh-banner-gradient-enabled');

themeStore.setTheme({ banner: false });
await nextTick();
expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');
} finally {
if (previousGradient) style.setProperty('--banner-gradient', previousGradient);
else style.removeProperty('--banner-gradient');
}
});

it('omits the banner gradient styling hook only when the theme disables it', () => {
it('disables the backdrop without a banner', () => {
const themeStore = useThemeStore();
themeStore.setTheme({
name: 'white',
Expand All @@ -235,9 +265,8 @@ describe('Header.standalone.vue theme layout', () => {
global: { plugins: [pinia], stubs },
});
wrappers.push(wrapper);
// The layer is always present; `--banner-gradient` is null when disabled, so
// it self-gates in CSS rather than by conditional rendering.
expect(wrapper.find('.unraid-banner-gradient-layer').exists()).toBe(true);
expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');
expect(wrapper.find('.uh-meta-over-banner').exists()).toBe(false);
});
});
18 changes: 9 additions & 9 deletions web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

exports[`Header.standalone.vue theme layout > renders a consistent layout on the azure theme 1`] = `
{
"gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
"logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
"gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
"logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": true,
Expand All @@ -17,8 +17,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the

exports[`Header.standalone.vue theme layout > renders a consistent layout on the black theme 1`] = `
{
"gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
"logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
"gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
"logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": false,
Expand All @@ -32,8 +32,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the

exports[`Header.standalone.vue theme layout > renders a consistent layout on the gray theme 1`] = `
{
"gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
"logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
"gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
"logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": true,
Expand All @@ -47,8 +47,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the

exports[`Header.standalone.vue theme layout > renders a consistent layout on the white theme 1`] = `
{
"gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
"logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
"gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
"logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": false,
Expand All @@ -60,6 +60,6 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the
}
`;

exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]"`;
exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]"`;

exports[`Header.standalone.vue theme layout > renders the theme header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 text-header-text-primary w-[14rem] xs:w-[16rem]"`;
1 change: 1 addition & 0 deletions web/src/assets/UN-logotype-gradient.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 1 addition & 5 deletions web/src/components/Brand/Avatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser
class="absolute inset-0 z-10 h-9 w-9 overflow-hidden rounded-full object-cover"
/>
<template v-else>
<BrandMark
gradient-start="#fff"
gradient-stop="#fff"
class="absolute z-10 h-9 w-9 p-[6px] opacity-100"
/>
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="absolute z-10 h-6 w-6 opacity-100" />
</template>
</figure>
</template>
Loading
Loading