mirror of
https://github.com/unraid/api.git
synced 2025-12-17 14:54:20 -06:00
<!-- 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 -->
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
import { defineComponent, h } from 'vue';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ComponentMapping } from '~/components/Wrapper/component-registry';
|
|
import type { MockInstance } from 'vitest';
|
|
|
|
// Mock @nuxt/ui components
|
|
vi.mock('@nuxt/ui/components/App.vue', () => ({
|
|
default: defineComponent({
|
|
name: 'UApp',
|
|
setup(_, { slots }) {
|
|
return () => h('div', { class: 'u-app' }, slots.default?.());
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@nuxt/ui/vue-plugin', () => ({
|
|
default: {
|
|
install: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock component registry
|
|
const mockComponentMappings: ComponentMapping[] = [];
|
|
vi.mock('~/components/Wrapper/component-registry', () => ({
|
|
componentMappings: mockComponentMappings,
|
|
}));
|
|
|
|
// Mock dependencies
|
|
|
|
const mockApolloClient = { query: vi.fn(), mutate: vi.fn() };
|
|
vi.mock('~/helpers/create-apollo-client', () => ({
|
|
client: mockApolloClient,
|
|
}));
|
|
|
|
const mockGlobalPinia = {
|
|
install: vi.fn(),
|
|
use: vi.fn(),
|
|
_a: null,
|
|
_e: null,
|
|
_s: new Map(),
|
|
state: {},
|
|
};
|
|
vi.mock('~/store/globalPinia', () => ({
|
|
globalPinia: mockGlobalPinia,
|
|
}));
|
|
|
|
const mockI18n = {
|
|
global: {
|
|
locale: { value: 'en_US' },
|
|
availableLocales: ['en_US'],
|
|
setLocaleMessage: vi.fn(),
|
|
},
|
|
install: vi.fn(),
|
|
};
|
|
const mockCreateI18nInstance = vi.fn(() => mockI18n);
|
|
const mockEnsureLocale = vi.fn().mockResolvedValue('en_US');
|
|
const mockGetWindowLocale = vi.fn<() => string | undefined>(() => undefined);
|
|
|
|
vi.mock('~/helpers/i18n-loader', () => ({
|
|
createI18nInstance: mockCreateI18nInstance,
|
|
ensureLocale: mockEnsureLocale,
|
|
getWindowLocale: mockGetWindowLocale,
|
|
}));
|
|
|
|
describe('mount-engine', () => {
|
|
let mountUnifiedApp: typeof import('~/components/Wrapper/mount-engine').mountUnifiedApp;
|
|
let autoMountAllComponents: typeof import('~/components/Wrapper/mount-engine').autoMountAllComponents;
|
|
let TestComponent: ReturnType<typeof defineComponent>;
|
|
let consoleWarnSpy: MockInstance;
|
|
let consoleErrorSpy: MockInstance;
|
|
|
|
beforeEach(async () => {
|
|
// Clear component mappings
|
|
mockComponentMappings.length = 0;
|
|
|
|
// Import fresh module
|
|
vi.resetModules();
|
|
mockCreateI18nInstance.mockClear();
|
|
mockEnsureLocale.mockClear();
|
|
mockGetWindowLocale.mockReset();
|
|
mockGetWindowLocale.mockReturnValue(undefined);
|
|
mockI18n.install.mockClear();
|
|
mockI18n.global.locale.value = 'en_US';
|
|
mockI18n.global.availableLocales = ['en_US'];
|
|
mockI18n.global.setLocaleMessage.mockClear();
|
|
const module = await import('~/components/Wrapper/mount-engine');
|
|
mountUnifiedApp = module.mountUnifiedApp;
|
|
autoMountAllComponents = module.autoMountAllComponents;
|
|
|
|
TestComponent = defineComponent({
|
|
name: 'TestComponent',
|
|
props: {
|
|
message: {
|
|
type: String,
|
|
default: 'Hello',
|
|
},
|
|
},
|
|
setup(props) {
|
|
return () => h('div', { class: 'test-component' }, props.message);
|
|
},
|
|
});
|
|
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
vi.clearAllMocks();
|
|
|
|
// Clean up DOM
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = '';
|
|
// Clean up global references
|
|
// Clean up any window references if needed
|
|
});
|
|
|
|
describe('mountUnifiedApp', () => {
|
|
it('should create and mount a unified app with shared context', async () => {
|
|
// Add a component mapping
|
|
const element = document.createElement('div');
|
|
element.id = 'test-app';
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#test-app',
|
|
appId: 'test-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
const app = await mountUnifiedApp();
|
|
|
|
expect(app).toBeTruthy();
|
|
expect(mockI18n.install).toHaveBeenCalled();
|
|
expect(mockGlobalPinia.install).toHaveBeenCalled();
|
|
|
|
// Wait for async component to render
|
|
await vi.waitFor(() => {
|
|
expect(element.querySelector('.test-component')).toBeTruthy();
|
|
});
|
|
|
|
// Check that component was rendered
|
|
expect(element.textContent).toContain('Hello');
|
|
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
|
expect(element.classList.contains('unapi')).toBe(true);
|
|
});
|
|
|
|
it('should parse props from element attributes', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'test-app';
|
|
element.setAttribute('message', 'Attribute Message');
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#test-app',
|
|
appId: 'test-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Wait for async component to render
|
|
await vi.waitFor(() => {
|
|
expect(element.textContent).toContain('Attribute Message');
|
|
});
|
|
});
|
|
|
|
it('should handle JSON props from attributes', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'test-app';
|
|
element.setAttribute('message', '{"text": "JSON Message"}');
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#test-app',
|
|
appId: 'test-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// The component receives the parsed JSON object
|
|
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
|
|
});
|
|
|
|
it('should handle HTML-encoded JSON in attributes', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'test-app';
|
|
element.setAttribute('message', '{"text": "Encoded"}');
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#test-app',
|
|
appId: 'test-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
expect(element.getAttribute('message')).toBe('{"text": "Encoded"}');
|
|
});
|
|
|
|
it('should handle multiple selector aliases', async () => {
|
|
const element1 = document.createElement('div');
|
|
element1.id = 'app1';
|
|
document.body.appendChild(element1);
|
|
|
|
const element2 = document.createElement('div');
|
|
element2.className = 'app-alt';
|
|
document.body.appendChild(element2);
|
|
|
|
// Component with multiple selector aliases - only first match mounts
|
|
mockComponentMappings.push({
|
|
selector: ['#app1', '.app-alt'],
|
|
appId: 'multi-selector',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Wait for async component to render
|
|
await vi.waitFor(() => {
|
|
expect(element1.querySelector('.test-component')).toBeTruthy();
|
|
});
|
|
|
|
// Only the first matching element should be mounted
|
|
expect(element1.getAttribute('data-vue-mounted')).toBe('true');
|
|
|
|
// Second element should not be mounted (first match wins)
|
|
expect(element2.querySelector('.test-component')).toBeFalsy();
|
|
expect(element2.getAttribute('data-vue-mounted')).toBeNull();
|
|
});
|
|
|
|
it('should handle async component loaders', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'async-app';
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#async-app',
|
|
appId: 'async-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Wait for component to mount
|
|
await vi.waitFor(() => {
|
|
expect(element.querySelector('.test-component')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('should skip already mounted elements', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'already-mounted';
|
|
element.setAttribute('data-vue-mounted', 'true');
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#already-mounted',
|
|
appId: 'already-mounted',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Should not mount to already mounted element
|
|
expect(element.querySelector('.test-component')).toBeFalsy();
|
|
});
|
|
|
|
it('should handle missing elements gracefully', async () => {
|
|
mockComponentMappings.push({
|
|
selector: '#non-existent',
|
|
appId: 'non-existent',
|
|
component: TestComponent,
|
|
});
|
|
|
|
const app = await mountUnifiedApp();
|
|
|
|
// Should still create the app successfully
|
|
expect(app).toBeTruthy();
|
|
// No errors should be thrown
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should error on invalid component mapping', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'invalid-app';
|
|
document.body.appendChild(element);
|
|
|
|
// Invalid mapping - no component
|
|
mockComponentMappings.push({
|
|
selector: '#invalid-app',
|
|
appId: 'invalid-app',
|
|
} as ComponentMapping);
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Should log error for missing component
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'[UnifiedMount] No component defined for invalid-app'
|
|
);
|
|
|
|
// Component should not be rendered without a valid component
|
|
expect(element.querySelector('.test-component')).toBeFalsy();
|
|
expect(element.getAttribute('data-vue-mounted')).toBeNull();
|
|
});
|
|
|
|
it('should create hidden root element if not exists', async () => {
|
|
await mountUnifiedApp();
|
|
|
|
const rootElement = document.getElementById('unraid-unified-root');
|
|
expect(rootElement).toBeTruthy();
|
|
expect(rootElement?.style.display).toBe('none');
|
|
});
|
|
|
|
it('should reuse existing root element', async () => {
|
|
// Create root element first
|
|
const existingRoot = document.createElement('div');
|
|
existingRoot.id = 'unraid-unified-root';
|
|
document.body.appendChild(existingRoot);
|
|
|
|
await mountUnifiedApp();
|
|
|
|
const rootElement = document.getElementById('unraid-unified-root');
|
|
expect(rootElement).toBe(existingRoot);
|
|
});
|
|
|
|
it('should wrap components in UApp for Nuxt UI support', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'wrapped-app';
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#wrapped-app',
|
|
appId: 'wrapped-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Wait for async component to render
|
|
await vi.waitFor(() => {
|
|
expect(element.querySelector('.u-app')).toBeTruthy();
|
|
});
|
|
|
|
// Check that UApp wrapper is present
|
|
expect(element.querySelector('.u-app .test-component')).toBeTruthy();
|
|
});
|
|
|
|
it('should share app context across all components', async () => {
|
|
const element1 = document.createElement('div');
|
|
element1.id = 'app1';
|
|
document.body.appendChild(element1);
|
|
|
|
const element2 = document.createElement('div');
|
|
element2.id = 'app2';
|
|
document.body.appendChild(element2);
|
|
|
|
mockComponentMappings.push(
|
|
{
|
|
selector: '#app1',
|
|
appId: 'app1',
|
|
component: TestComponent,
|
|
},
|
|
{
|
|
selector: '#app2',
|
|
appId: 'app2',
|
|
component: TestComponent,
|
|
}
|
|
);
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Wait for async components to render
|
|
await vi.waitFor(() => {
|
|
expect(element1.querySelector('.test-component')).toBeTruthy();
|
|
expect(element2.querySelector('.test-component')).toBeTruthy();
|
|
});
|
|
|
|
// Only one Pinia instance should be installed
|
|
expect(mockGlobalPinia.install).toHaveBeenCalledTimes(1);
|
|
// Only one i18n instance should be installed
|
|
expect(mockI18n.install).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('autoMountAllComponents', () => {
|
|
it('should call mountUnifiedApp', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'auto-app';
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#auto-app',
|
|
appId: 'auto-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await autoMountAllComponents();
|
|
|
|
// Wait for async component to render
|
|
await vi.waitFor(() => {
|
|
expect(element.querySelector('.test-component')).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('i18n setup', () => {
|
|
it('should setup i18n with default locale', async () => {
|
|
await mountUnifiedApp();
|
|
expect(mockCreateI18nInstance).toHaveBeenCalled();
|
|
expect(mockEnsureLocale).toHaveBeenCalledWith(mockI18n, undefined);
|
|
expect(mockI18n.install).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should request window locale when available', async () => {
|
|
mockGetWindowLocale.mockReturnValue('ja_JP');
|
|
|
|
await mountUnifiedApp();
|
|
|
|
expect(mockEnsureLocale).toHaveBeenCalledWith(mockI18n, 'ja_JP');
|
|
});
|
|
});
|
|
|
|
describe('global exposure', () => {
|
|
it('should expose globalPinia globally', () => {
|
|
expect(window.globalPinia).toBeDefined();
|
|
expect(window.globalPinia).toBe(mockGlobalPinia);
|
|
});
|
|
});
|
|
|
|
describe('performance debugging', () => {
|
|
it('should not log timing by default', async () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'perf-app';
|
|
document.body.appendChild(element);
|
|
|
|
mockComponentMappings.push({
|
|
selector: '#perf-app',
|
|
appId: 'perf-app',
|
|
component: TestComponent,
|
|
});
|
|
|
|
await mountUnifiedApp();
|
|
|
|
// Should not log timing information when PERF_DEBUG is false
|
|
expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted'));
|
|
});
|
|
});
|
|
});
|