Files
api/web/__test__/components/ChangelogModal.test.ts
Eli Bosley 31c41027fc feat: translations now use crowdin (translate.unraid.net) (#1739)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- App-wide internationalization: dynamic locale detection/loading, many
new locale bundles, and CLI helpers to extract/sort translation keys.

- **Accessibility**
  - Brand button supports keyboard activation (Enter/Space).

- **Documentation**
  - Internationalization guidance added to API and Web READMEs.

- **Refactor**
- UI updated to use centralized i18n keys and a unified locale loading
approach.

- **Tests**
  - Test utilities updated to support i18n and localized assertions.

- **Chores**
- Crowdin config and i18n scripts added; runtime locale exposed for
selection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 16:56:08 -04:00

175 lines
5.7 KiB
TypeScript

import { ref } from 'vue';
import { mount } from '@vue/test-utils';
import { DOCS } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
import { createTestI18n } from '../utils/i18n';
vi.mock('@unraid/ui', () => ({
BrandButton: { template: '<button><slot /></button>' },
BrandLoading: { template: '<div class="brand-loading" />' },
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
ResponsiveModal: { template: '<div><slot /></div>', props: ['open'] },
ResponsiveModalFooter: { template: '<div><slot /></div>' },
ResponsiveModalHeader: { template: '<div><slot /></div>' },
ResponsiveModalTitle: { template: '<div><slot /></div>' },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
ArrowRightIcon: { template: '<svg />' },
ArrowTopRightOnSquareIcon: { template: '<svg />' },
KeyIcon: { template: '<svg />' },
ServerStackIcon: { template: '<svg />' },
}));
vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({
default: { template: '<div />', props: ['changelog', 'version', 'date', 'changelogPretty'] },
}));
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia');
const isActualStore = (candidate: unknown): candidate is Parameters<typeof actual.storeToRefs>[0] =>
Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
const isRefLike = (input: unknown): input is { value: unknown } =>
Boolean(input && typeof input === 'object' && 'value' in input);
return {
...actual,
storeToRefs: (store: unknown) => {
if (isActualStore(store)) {
return actual.storeToRefs(store);
}
if (!store || typeof store !== 'object') {
return {};
}
const refs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
if (isRefLike(value)) {
refs[key] = value;
}
}
return refs;
},
};
});
const mockRenew = vi.fn();
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => ({
renew: mockRenew,
}),
}));
const mockAvailableWithRenewal = ref(false);
const mockReleaseForUpdate = ref(null);
const mockChangelogModalVisible = ref(false);
const mockSetReleaseForUpdate = vi.fn();
const mockFetchAndConfirmInstall = vi.fn();
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
availableWithRenewal: mockAvailableWithRenewal,
releaseForUpdate: mockReleaseForUpdate,
changelogModalVisible: mockChangelogModalVisible,
setReleaseForUpdate: mockSetReleaseForUpdate,
fetchAndConfirmInstall: mockFetchAndConfirmInstall,
}),
}));
const mockDarkMode = ref(false);
const mockTheme = ref({ name: 'default' });
vi.mock('~/store/theme', () => ({
useThemeStore: () => ({
darkMode: mockDarkMode,
theme: mockTheme,
}),
}));
describe('ChangelogModal iframeSrc', () => {
const mountWithChangelog = (changelogPretty: string | null) =>
mount(ChangelogModal, {
props: {
open: true,
release: {
version: '6.12.0',
changelogPretty: changelogPretty ?? undefined,
changelog: 'Raw changelog markdown',
name: 'Unraid OS 6.12.0',
date: '2024-01-01',
},
},
global: {
plugins: [createTestI18n()],
},
});
beforeEach(() => {
mockRenew.mockClear();
mockAvailableWithRenewal.value = false;
mockReleaseForUpdate.value = null;
mockChangelogModalVisible.value = false;
mockSetReleaseForUpdate.mockClear();
mockFetchAndConfirmInstall.mockClear();
mockDarkMode.value = false;
mockTheme.value = { name: 'default' };
});
it('sanitizes absolute docs URLs to embed within DOCS origin', () => {
const entry = `${DOCS.origin}/go/release-notes/?foo=bar#section`;
const wrapper = mountWithChangelog(entry);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/go/release-notes/');
expect(iframeUrl.searchParams.get('embed')).toBe('1');
expect(iframeUrl.searchParams.get('theme')).toBe('light');
expect(iframeUrl.searchParams.get('entry')).toBe('/go/release-notes/?foo=bar#section');
});
it('builds DOCS-relative URL when provided a path entry', () => {
const wrapper = mountWithChangelog('updates/6.12?tab=notes#overview');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/updates/6.12');
expect(iframeUrl.searchParams.get('entry')).toBe('/updates/6.12?tab=notes#overview');
});
it('applies dark theme when current UI theme requires it', () => {
mockTheme.value = { name: 'azure' };
const wrapper = mountWithChangelog(`${DOCS.origin}/release/6.12`);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.searchParams.get('theme')).toBe('dark');
});
it('rejects non-docs origins and returns null', () => {
const wrapper = mountWithChangelog('https://example.com/bad');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
it('rejects non-http(s) protocols', () => {
const wrapper = mountWithChangelog('javascript:alert(1)');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
});