diff --git a/api/src/unraid-api/rest/rest.service.test.ts b/api/src/unraid-api/rest/rest.service.test.ts new file mode 100644 index 000000000..34d481af7 --- /dev/null +++ b/api/src/unraid-api/rest/rest.service.test.ts @@ -0,0 +1,350 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { ReadStream, Stats } from 'node:fs'; +import { createReadStream } from 'node:fs'; +import { stat, writeFile } from 'node:fs/promises'; +import { Readable } from 'node:stream'; + +import { execa, ExecaError } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js'; +import { + getBannerPathIfPresent, + getCasePathIfPresent, +} from '@app/core/utils/images/image-file-helpers.js'; +import { getters } from '@app/store/index.js'; +import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; +import { RestService } from '@app/unraid-api/rest/rest.service.js'; + +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('execa'); +vi.mock('@app/store/index.js'); +vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({ + getBannerPathIfPresent: vi.fn(), + getCasePathIfPresent: vi.fn(), +})); + +describe('RestService', () => { + let service: RestService; + let apiReportService: ApiReportService; + + beforeEach(async () => { + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RestService, + { + provide: ApiReportService, + useValue: { + generateReport: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RestService); + apiReportService = module.get(ApiReportService); + }); + + describe('getLogs', () => { + const mockLogPath = '/usr/local/emhttp/logs/unraid-api'; + const mockGraphqlApiLog = '/var/log/graphql-api.log'; + const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz'; + + beforeEach(() => { + vi.mocked(getters).paths = vi.fn().mockReturnValue({ + 'log-base': mockLogPath, + }); + // Mock saveApiReport to avoid side effects + vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined); + }); + + it('should create and return log archive successfully', async () => { + const mockStream: ReadStream = Readable.from([]) as ReadStream; + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath || path === mockZipPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + vi.mocked(execa).mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + vi.mocked(createReadStream).mockReturnValue(mockStream); + + const result = await service.getLogs(); + + expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], { + timeout: 60000, + reject: true, + }); + expect(createReadStream).toHaveBeenCalledWith(mockZipPath); + expect(result).toBe(mockStream); + }); + + it('should include graphql-api.log when it exists', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + vi.mocked(execa).mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream); + + await service.getLogs(); + + expect(execa).toHaveBeenCalledWith( + 'tar', + ['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog], + { + timeout: 60000, + reject: true, + } + ); + }); + + it('should handle timeout errors with detailed message', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + + const timeoutError = new Error('Command timed out') as ExecaError; + timeoutError.timedOut = true; + timeoutError.command = + 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; + timeoutError.exitCode = undefined; + timeoutError.stderr = ''; + timeoutError.stdout = ''; + + vi.mocked(execa).mockRejectedValue(timeoutError); + + await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds'); + }); + + it('should handle command failure with exit code and stderr', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + + const execError = new Error('Command failed') as ExecaError; + execError.exitCode = 1; + execError.command = + 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; + execError.stderr = 'tar: Cannot create archive'; + execError.stdout = ''; + execError.shortMessage = 'Command failed with exit code 1'; + + vi.mocked(execa).mockRejectedValue(execError); + + await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1'); + await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive'); + }); + + it('should handle case when tar succeeds but zip file is not created', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + // Zip file doesn't exist after tar command + return Promise.reject(new Error('File not found')); + }); + vi.mocked(execa).mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + + await expect(service.getLogs()).rejects.toThrow( + 'Failed to create log zip - tar file not found after successful command' + ); + }); + + it('should throw error when log path does not exist', async () => { + vi.mocked(stat).mockRejectedValue(new Error('File not found')); + + await expect(service.getLogs()).rejects.toThrow('No logs to download'); + }); + + it('should handle generic errors', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + + const genericError = new Error('Unexpected error'); + vi.mocked(execa).mockRejectedValue(genericError); + + await expect(service.getLogs()).rejects.toThrow( + 'Failed to create logs archive: Unexpected error' + ); + }); + + it('should handle errors with stdout in addition to stderr', async () => { + vi.mocked(stat).mockImplementation((path) => { + if (path === mockLogPath) { + return Promise.resolve({ isFile: () => true } as unknown as Stats); + } + return Promise.reject(new Error('File not found')); + }); + + const execError = new Error('Command failed') as ExecaError; + execError.exitCode = 1; + execError.command = + 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; + execError.stderr = 'tar: Error'; + execError.stdout = 'Processing archive...'; + execError.shortMessage = 'Command failed with exit code 1'; + + vi.mocked(execa).mockRejectedValue(execError); + + await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive'); + }); + }); + + describe('saveApiReport', () => { + it('should generate and save API report', async () => { + const mockReport: ApiReportData = { + timestamp: new Date().toISOString(), + connectionStatus: { running: 'yes' }, + system: { + name: 'Test Server', + version: '6.12.0', + machineId: 'test-machine-id', + }, + connect: { + installed: false, + }, + config: { + valid: true, + }, + services: { + cloud: null, + minigraph: null, + allServices: [], + }, + }; + const mockPath = '/test/report.json'; + + vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await service.saveApiReport(mockPath); + + expect(apiReportService.generateReport).toHaveBeenCalled(); + expect(writeFile).toHaveBeenCalledWith( + mockPath, + JSON.stringify(mockReport, null, 2), + 'utf-8' + ); + }); + + it('should handle errors when generating report', async () => { + const mockPath = '/test/report.json'; + + vi.mocked(apiReportService.generateReport).mockRejectedValue( + new Error('Report generation failed') + ); + + // Should not throw, just log warning + await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined(); + expect(apiReportService.generateReport).toHaveBeenCalled(); + }); + }); + + describe('getCustomizationPath', () => { + it('should return banner path when type is banner', async () => { + const mockBannerPath = '/path/to/banner.png'; + vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath); + + const result = await service.getCustomizationPath('banner'); + + expect(getBannerPathIfPresent).toHaveBeenCalled(); + expect(result).toBe(mockBannerPath); + }); + + it('should return case path when type is case', async () => { + const mockCasePath = '/path/to/case.png'; + vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath); + + const result = await service.getCustomizationPath('case'); + + expect(getCasePathIfPresent).toHaveBeenCalled(); + expect(result).toBe(mockCasePath); + }); + + it('should return null when no banner found', async () => { + vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); + + const result = await service.getCustomizationPath('banner'); + + expect(result).toBeNull(); + }); + + it('should return null when no case found', async () => { + vi.mocked(getCasePathIfPresent).mockResolvedValue(null); + + const result = await service.getCustomizationPath('case'); + + expect(result).toBeNull(); + }); + }); + + describe('getCustomizationStream', () => { + it('should return read stream for banner', async () => { + const mockPath = '/path/to/banner.png'; + const mockStream: ReadStream = Readable.from([]) as ReadStream; + + vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath); + vi.mocked(createReadStream).mockReturnValue(mockStream); + + const result = await service.getCustomizationStream('banner'); + + expect(getBannerPathIfPresent).toHaveBeenCalled(); + expect(createReadStream).toHaveBeenCalledWith(mockPath); + expect(result).toBe(mockStream); + }); + + it('should return read stream for case', async () => { + const mockPath = '/path/to/case.png'; + const mockStream: ReadStream = Readable.from([]) as ReadStream; + + vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath); + vi.mocked(createReadStream).mockReturnValue(mockStream); + + const result = await service.getCustomizationStream('case'); + + expect(getCasePathIfPresent).toHaveBeenCalled(); + expect(createReadStream).toHaveBeenCalledWith(mockPath); + expect(result).toBe(mockStream); + }); + + it('should throw error when no banner found', async () => { + vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); + + await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found'); + }); + + it('should throw error when no case found', async () => { + vi.mocked(getCasePathIfPresent).mockResolvedValue(null); + + await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found'); + }); + }); +}); diff --git a/api/src/unraid-api/rest/rest.service.ts b/api/src/unraid-api/rest/rest.service.ts index 4b43a875a..9ccb30676 100644 --- a/api/src/unraid-api/rest/rest.service.ts +++ b/api/src/unraid-api/rest/rest.service.ts @@ -4,6 +4,7 @@ import { createReadStream } from 'node:fs'; import { stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import type { ExecaError } from 'execa'; import { execa } from 'execa'; import { @@ -31,6 +32,8 @@ export class RestService { async getLogs(): Promise { const logPath = getters.paths()['log-base']; + const graphqlApiLog = '/var/log/graphql-api.log'; + try { await this.saveApiReport(join(logPath, 'report.json')); } catch (error) { @@ -41,16 +44,62 @@ export class RestService { const logPathExists = Boolean(await stat(logPath).catch(() => null)); if (logPathExists) { try { - await execa('tar', ['-czf', zipToWrite, logPath]); + // Build tar command arguments + const tarArgs = ['-czf', zipToWrite, logPath]; + + // Check if graphql-api.log exists and add it to the archive + const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null)); + if (graphqlLogExists) { + tarArgs.push(graphqlApiLog); + this.logger.debug('Including graphql-api.log in archive'); + } + + // Execute tar with timeout and capture output + await execa('tar', tarArgs, { + timeout: 60000, // 60 seconds timeout for tar operation + reject: true, // Throw on non-zero exit (default behavior) + }); + const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null)); if (tarFileExists) { return createReadStream(zipToWrite); } else { - throw new Error('Failed to create log zip'); + throw new Error( + 'Failed to create log zip - tar file not found after successful command' + ); } } catch (error) { - throw new Error('Failed to create logs'); + // Build detailed error message with execa's built-in error info + let errorMessage = 'Failed to create logs archive'; + + if (error && typeof error === 'object' && 'command' in error) { + const execaError = error as ExecaError; + + if (execaError.timedOut) { + errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`; + } else if (execaError.exitCode !== undefined) { + errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`; + } + + // Add stderr/stdout if available + if (execaError.stderr) { + errorMessage += `. Stderr: ${execaError.stderr}`; + } + if (execaError.stdout) { + errorMessage += `. Stdout: ${execaError.stdout}`; + } + + // Include the short message from execa + if (execaError.shortMessage) { + errorMessage += `. Details: ${execaError.shortMessage}`; + } + } else if (error instanceof Error) { + errorMessage += `: ${error.message}`; + } + + this.logger.error(errorMessage, error); + throw new Error(errorMessage); } } else { throw new Error('No logs to download'); diff --git a/package.json b/package.json index cf5ce6cc2..ded5e8ea4 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,17 @@ "pre-commit": "pnpm lint-staged" }, "lint-staged": { - "*.{js,jsx,ts,tsx,vue}": [ - "pnpm lint:fix" + "api/**/*.{js,ts}": [ + "pnpm --filter api lint:fix" + ], + "web/**/*.{js,ts,tsx,vue}": [ + "pnpm --filter web lint:fix" + ], + "unraid-ui/**/*.{js,ts,tsx,vue}": [ + "pnpm --filter @unraid/ui lint:fix" + ], + "packages/**/*.{js,ts}": [ + "eslint --fix" ] }, "packageManager": "pnpm@10.15.0" diff --git a/unraid-ui/eslint.config.ts b/unraid-ui/eslint.config.ts index 3f2797bf7..db4eac09a 100644 --- a/unraid-ui/eslint.config.ts +++ b/unraid-ui/eslint.config.ts @@ -104,6 +104,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files parser: tseslint.parser, parserOptions: { ...commonLanguageOptions, + tsconfigRootDir: import.meta.dirname, ecmaFeatures: { jsx: true, }, @@ -128,6 +129,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files parserOptions: { ...commonLanguageOptions, parser: tseslint.parser, + tsconfigRootDir: import.meta.dirname, ecmaFeatures: { jsx: true, }, diff --git a/unraid-ui/src/components/ui/dropdown-menu/DropdownMenuContent.vue b/unraid-ui/src/components/ui/dropdown-menu/DropdownMenuContent.vue index 92f1677ee..1995d8f3f 100644 --- a/unraid-ui/src/components/ui/dropdown-menu/DropdownMenuContent.vue +++ b/unraid-ui/src/components/ui/dropdown-menu/DropdownMenuContent.vue @@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport(); v-bind="forwarded" :class=" cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md', + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-[103] min-w-32 overflow-hidden rounded-lg border p-1 shadow-md', props.class ) " diff --git a/unraid-ui/src/composables/useTeleport.test.ts b/unraid-ui/src/composables/useTeleport.test.ts new file mode 100644 index 000000000..c7754c124 --- /dev/null +++ b/unraid-ui/src/composables/useTeleport.test.ts @@ -0,0 +1,169 @@ +import useTeleport from '@/composables/useTeleport'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock Vue's computed while preserving other exports +vi.mock('vue', async () => ({ + ...(await vi.importActual('vue')), + computed: vi.fn((fn) => { + const result = { value: fn() }; + return result; + }), +})); + +describe('useTeleport', () => { + beforeEach(() => { + // Clear the DOM before each test + document.body.innerHTML = ''; + document.head.innerHTML = ''; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return teleportTarget computed property', () => { + const { teleportTarget } = useTeleport(); + expect(teleportTarget).toBeDefined(); + expect(teleportTarget).toHaveProperty('value'); + }); + + it('should return #modals when element with id="modals" exists', () => { + // Create element with id="modals" + const modalsDiv = document.createElement('div'); + modalsDiv.id = 'modals'; + document.body.appendChild(modalsDiv); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#modals'); + }); + + it('should prioritize #modals id over mounted unraid-modals', () => { + // Create both elements + const modalsDiv = document.createElement('div'); + modalsDiv.id = 'modals'; + document.body.appendChild(modalsDiv); + + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'true'); + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#modals'); + }); + + it('should return mounted unraid-modals with inner #modals div', () => { + // Create mounted unraid-modals with inner modals div + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'true'); + + const innerModals = document.createElement('div'); + innerModals.id = 'modals'; + unraidModals.appendChild(innerModals); + + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#modals'); + }); + + it('should add id to mounted unraid-modals when no inner modals div exists', () => { + // Create mounted unraid-modals without inner div + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'true'); + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(unraidModals.id).toBe('unraid-modals-teleport-target'); + expect(teleportTarget.value).toBe('#unraid-modals-teleport-target'); + }); + + it('should use existing id of mounted unraid-modals if present', () => { + // Create mounted unraid-modals with existing id + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'true'); + unraidModals.id = 'custom-modals-id'; + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#custom-modals-id'); + }); + + it('should ignore unmounted unraid-modals elements', () => { + // Create unmounted unraid-modals (without data-vue-mounted attribute) + const unraidModals = document.createElement('unraid-modals'); + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('body'); + }); + + it('should ignore unraid-modals with data-vue-mounted="false"', () => { + // Create unraid-modals with data-vue-mounted="false" + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'false'); + document.body.appendChild(unraidModals); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('body'); + }); + + it('should return body as fallback when no suitable target exists', () => { + // No elements in DOM + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('body'); + }); + + it('should handle multiple unraid-modals elements correctly', () => { + // Create multiple unraid-modals, only one mounted + const unmountedModals1 = document.createElement('unraid-modals'); + document.body.appendChild(unmountedModals1); + + const mountedModals = document.createElement('unraid-modals'); + mountedModals.setAttribute('data-vue-mounted', 'true'); + mountedModals.id = 'mounted-modals'; + document.body.appendChild(mountedModals); + + const unmountedModals2 = document.createElement('unraid-modals'); + document.body.appendChild(unmountedModals2); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#mounted-modals'); + }); + + it('should handle nested modal elements correctly', () => { + // Create nested structure + const container = document.createElement('div'); + + const unraidModals = document.createElement('unraid-modals'); + unraidModals.setAttribute('data-vue-mounted', 'true'); + + const innerDiv = document.createElement('div'); + const innerModals = document.createElement('div'); + innerModals.id = 'modals'; + + innerDiv.appendChild(innerModals); + unraidModals.appendChild(innerDiv); + container.appendChild(unraidModals); + document.body.appendChild(container); + + const { teleportTarget } = useTeleport(); + expect(teleportTarget.value).toBe('#modals'); + }); + + it('should be reactive to DOM changes', () => { + const { teleportTarget } = useTeleport(); + + // Initially should be body + expect(teleportTarget.value).toBe('body'); + + // Add modals element + const modalsDiv = document.createElement('div'); + modalsDiv.id = 'modals'; + document.body.appendChild(modalsDiv); + + // Recreate the composable to test updated DOM state + const { teleportTarget: newTeleportTarget } = useTeleport(); + expect(newTeleportTarget.value).toBe('#modals'); + }); +}); diff --git a/unraid-ui/src/composables/useTeleport.ts b/unraid-ui/src/composables/useTeleport.ts index d333de19e..75739c8c4 100644 --- a/unraid-ui/src/composables/useTeleport.ts +++ b/unraid-ui/src/composables/useTeleport.ts @@ -1,12 +1,31 @@ -import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container'; -import { onMounted, ref } from 'vue'; +import { computed } from 'vue'; const useTeleport = () => { - const teleportTarget = ref('body'); + // Computed property that finds the correct teleport target + const teleportTarget = computed(() => { + // #modals should be unique (id), but let's be defensive + const modalsElement = document.getElementById('modals'); + if (modalsElement) return `#modals`; - onMounted(() => { - const container = ensureTeleportContainer(); - teleportTarget.value = container; + // Find only mounted unraid-modals components (data-vue-mounted="true") + // This ensures we don't target unmounted or duplicate elements + const mountedModals = document.querySelector('unraid-modals[data-vue-mounted="true"]'); + if (mountedModals) { + // Check if it has the inner #modals div + const innerModals = mountedModals.querySelector('#modals'); + if (innerModals && innerModals.id) { + return `#${innerModals.id}`; + } + // Use the mounted component itself as fallback + // Add a unique identifier if it doesn't have one + if (!mountedModals.id) { + mountedModals.id = 'unraid-modals-teleport-target'; + } + return `#${mountedModals.id}`; + } + + // Final fallback to body - modals component not mounted yet + return 'body'; }); return { diff --git a/unraid-ui/src/helpers/ensure-teleport-container.ts b/unraid-ui/src/helpers/ensure-teleport-container.ts deleted file mode 100644 index 7e09f76d4..000000000 --- a/unraid-ui/src/helpers/ensure-teleport-container.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Ensures the teleport container exists in the DOM. - * This is used by both the standalone mount script and unraid-ui components - * to ensure modals and other teleported content have a target. - */ -export function ensureTeleportContainer(): HTMLElement { - const containerId = 'unraid-teleport-container'; - - // Check if container already exists - let container = document.getElementById(containerId); - - // If it doesn't exist, create it - if (!container) { - container = document.createElement('div'); - container.id = containerId; - container.style.position = 'relative'; - container.classList.add('unapi'); - container.style.zIndex = '999999'; // Very high z-index to ensure it's always on top - document.body.appendChild(container); - } - - return container; -} diff --git a/unraid-ui/src/index.ts b/unraid-ui/src/index.ts index f97fb05b2..3cc345375 100644 --- a/unraid-ui/src/index.ts +++ b/unraid-ui/src/index.ts @@ -15,6 +15,3 @@ export * from '@/lib/utils'; export { default as useTeleport } from '@/composables/useTeleport'; export { useToast } from '@/composables/useToast'; export type { ToastInstance } from '@/composables/useToast'; - -// Helpers -export { ensureTeleportContainer } from '@/helpers/ensure-teleport-container'; diff --git a/unraid-ui/tsconfig.json b/unraid-ui/tsconfig.json index f3b68f101..9a64428f7 100644 --- a/unraid-ui/tsconfig.json +++ b/unraid-ui/tsconfig.json @@ -51,10 +51,6 @@ "exclude": [ "node_modules", "**/*.copy.vue", - "**/*copy.vue", - "**/*.test.ts", - "**/*.spec.ts", - "**/*.test.tsx", - "**/*.spec.tsx" + "**/*copy.vue" ] } diff --git a/unraid-ui/vite.config.ts b/unraid-ui/vite.config.ts index 4b10b9355..0d2524787 100644 --- a/unraid-ui/vite.config.ts +++ b/unraid-ui/vite.config.ts @@ -19,6 +19,17 @@ export default function createConfig() { dts({ insertTypesEntry: true, include: ['src/**/*.ts', 'src/**/*.vue'], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.spec.ts', + 'src/**/*.test.tsx', + 'src/**/*.spec.tsx', + 'src/**/*.test.vue', + 'src/**/*.spec.vue', + 'src/**/*.stories.*', + 'src/**/*.stories.{ts,tsx,vue}', + 'src/**/__tests__/**', + ], outDir: 'dist', rollupTypes: true, copyDtsFiles: true, diff --git a/web/__test__/components/HeaderOsVersion.test.ts b/web/__test__/components/HeaderOsVersion.test.ts index dc1257fbb..62537d33b 100644 --- a/web/__test__/components/HeaderOsVersion.test.ts +++ b/web/__test__/components/HeaderOsVersion.test.ts @@ -156,4 +156,28 @@ describe('HeaderOsVersion', () => { expect(findUpdateStatusComponent()).toBeNull(); }); + + it('removes logo class from logo wrapper on mount', async () => { + // Create a mock logo element + const logoElement = document.createElement('div'); + logoElement.classList.add('logo'); + document.body.appendChild(logoElement); + + // Mount component + const newWrapper = mount(HeaderOsVersion, { + global: { + plugins: [testingPinia], + }, + }); + + // Wait for nextTick to allow onMounted to complete + await nextTick(); + await nextTick(); // Double nextTick since onMounted uses nextTick internally + + expect(logoElement.classList.contains('logo')).toBe(false); + + // Cleanup + newWrapper.unmount(); + document.body.removeChild(logoElement); + }); }); diff --git a/web/__test__/components/Modals.test.ts b/web/__test__/components/Modals.test.ts new file mode 100644 index 000000000..ed743d0b8 --- /dev/null +++ b/web/__test__/components/Modals.test.ts @@ -0,0 +1,232 @@ +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 type { Pinia } from 'pinia'; + +import Modals from '~/components/Modals.standalone.vue'; +import { useCallbackActionsStore } from '~/store/callbackActions'; +import { useTrialStore } from '~/store/trial'; +import { useUpdateOsStore } from '~/store/updateOs'; + +// Mock child components +vi.mock('~/components/Activation/ActivationModal.vue', () => ({ + default: { + name: 'ActivationModal', + props: ['t'], + template: '
ActivationModal
', + }, +})); + +vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({ + default: { + name: 'UpdateOsChangelogModal', + props: ['t', 'open'], + template: '
ChangelogModal
', + }, +})); + +vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({ + default: { + name: 'UpdateOsCheckUpdateResponseModal', + props: ['t', 'open'], + template: '
CheckUpdateResponseModal
', + }, +})); + +vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({ + default: { + name: 'UpcCallbackFeedback', + props: ['t', 'open'], + template: '
CallbackFeedback
', + }, +})); + +vi.mock('~/components/UserProfile/Trial.vue', () => ({ + default: { + name: 'UpcTrial', + props: ['t', 'open'], + template: '
Trial
', + }, +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +describe('Modals.standalone.vue', () => { + let wrapper: VueWrapper; + let pinia: Pinia; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + + wrapper = mount(Modals, { + global: { + plugins: [pinia], + }, + }); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllMocks(); + }); + + it('should render modals container with correct id and ref', () => { + const modalsDiv = wrapper.find('#modals'); + expect(modalsDiv.exists()).toBe(true); + expect(modalsDiv.attributes('class')).toContain('relative'); + expect(modalsDiv.attributes('class')).toContain('z-[999999]'); + }); + + it('should render all modal components', () => { + expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'UpcTrial' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true); + }); + + it('should pass correct props to CallbackFeedback based on store state', async () => { + const callbackStore = useCallbackActionsStore(); + callbackStore.callbackStatus = 'loading'; + + await nextTick(); + + const callbackFeedback = wrapper.findComponent({ name: 'UpcCallbackFeedback' }); + expect(callbackFeedback.props('open')).toBe(true); + + callbackStore.callbackStatus = 'ready'; + await nextTick(); + expect(callbackFeedback.props('open')).toBe(false); + }); + + it('should pass correct props to Trial modal based on store state', async () => { + const trialStore = useTrialStore(); + // trialModalVisible is computed based on trialStatus + trialStore.trialStatus = 'trialStart'; + + await nextTick(); + + const trialModal = wrapper.findComponent({ name: 'UpcTrial' }); + expect(trialModal.props('open')).toBe(true); + + trialStore.trialStatus = 'ready'; + await nextTick(); + expect(trialModal.props('open')).toBe(false); + }); + + it('should pass correct props to UpdateOs modal based on store state', async () => { + const updateOsStore = useUpdateOsStore(); + updateOsStore.setModalOpen(true); + + await nextTick(); + + const updateOsModal = wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }); + expect(updateOsModal.props('open')).toBe(true); + + updateOsStore.setModalOpen(false); + await nextTick(); + expect(updateOsModal.props('open')).toBe(false); + }); + + it('should pass correct props to Changelog modal based on store state', async () => { + const updateOsStore = useUpdateOsStore(); + // changelogModalVisible is computed based on releaseForUpdate + updateOsStore.setReleaseForUpdate({ + version: '6.13.0', + name: 'Unraid 6.13.0', + date: '2024-01-01', + isNewer: true, + isEligible: true, + changelog: null, + sha256: null, + }); + + await nextTick(); + + const changelogModal = wrapper.findComponent({ name: 'UpdateOsChangelogModal' }); + expect(changelogModal.props('open')).toBe(true); + + updateOsStore.setReleaseForUpdate(null); + await nextTick(); + expect(changelogModal.props('open')).toBe(false); + }); + + it('should pass translation function to all modals', () => { + const components = [ + 'UpcCallbackFeedback', + 'UpcTrial', + 'UpdateOsCheckUpdateResponseModal', + 'UpdateOsChangelogModal', + 'ActivationModal', + ]; + + components.forEach((componentName) => { + const component = wrapper.findComponent({ name: componentName }); + expect(component.props('t')).toBeDefined(); + expect(typeof component.props('t')).toBe('function'); + }); + }); + + it('should use computed properties for reactive store access', async () => { + // Test that computed properties react to store changes + const callbackStore = useCallbackActionsStore(); + const trialStore = useTrialStore(); + const updateOsStore = useUpdateOsStore(); + + // Initially all should be closed/default + expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(false); + expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(false); + expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe( + false + ); + expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(false); + + // Update all stores using proper methods + callbackStore.callbackStatus = 'loading'; + trialStore.trialStatus = 'trialStart'; + updateOsStore.setModalOpen(true); + updateOsStore.setReleaseForUpdate({ + version: '6.13.0', + name: 'Unraid 6.13.0', + date: '2024-01-01', + isNewer: true, + isEligible: true, + changelog: null, + sha256: null, + }); + + await nextTick(); + + // All should be open now + expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(true); + expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(true); + expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(true); + expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(true); + }); + + it('should render modals container even when all modals are closed', () => { + const callbackStore = useCallbackActionsStore(); + const trialStore = useTrialStore(); + const updateOsStore = useUpdateOsStore(); + + // Set all modals to closed state + callbackStore.callbackStatus = 'ready'; + trialStore.trialStatus = 'ready'; + updateOsStore.setModalOpen(false); + updateOsStore.setReleaseForUpdate(null); + + const modalsDiv = wrapper.find('#modals'); + expect(modalsDiv.exists()).toBe(true); + // Container should still exist + expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true); + }); +}); diff --git a/web/__test__/components/Wrapper/component-registry.test.ts b/web/__test__/components/Wrapper/component-registry.test.ts new file mode 100644 index 000000000..a802fc9cc --- /dev/null +++ b/web/__test__/components/Wrapper/component-registry.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock Vue's defineAsyncComponent +vi.mock('vue', () => ({ + defineAsyncComponent: vi.fn((loader) => ({ loader, __asyncComponent: true })), +})); + +// Mock CSS imports +vi.mock('~/assets/main.css', () => ({})); +vi.mock('@unraid/ui/styles', () => ({})); + +// Mock all component imports +vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'HeaderOsVersion' })); +vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' })); +vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' })); +vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' })); +vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' })); +vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' })); +vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' })); +vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' })); +vi.mock('../CallbackHandler.standalone.vue', () => ({ default: 'CallbackHandler' })); +vi.mock('../Logs/LogViewer.standalone.vue', () => ({ default: 'LogViewer' })); +vi.mock('../SsoButton.standalone.vue', () => ({ default: 'SsoButton' })); +vi.mock('../Activation/WelcomeModal.standalone.vue', () => ({ default: 'WelcomeModal' })); +vi.mock('../UpdateOs.standalone.vue', () => ({ default: 'UpdateOs' })); +vi.mock('../DowngradeOs.standalone.vue', () => ({ default: 'DowngradeOs' })); +vi.mock('../DevSettings.vue', () => ({ default: 'DevSettings' })); +vi.mock('../ApiKeyPage.standalone.vue', () => ({ default: 'ApiKeyPage' })); +vi.mock('../ApiKeyAuthorize.standalone.vue', () => ({ default: 'ApiKeyAuthorize' })); +vi.mock('../DevModalTest.standalone.vue', () => ({ default: 'DevModalTest' })); +vi.mock('../LayoutViews/Detail/DetailTest.standalone.vue', () => ({ default: 'DetailTest' })); +vi.mock('@/components/ThemeSwitcher.standalone.vue', () => ({ default: 'ThemeSwitcher' })); +vi.mock('../ColorSwitcher.standalone.vue', () => ({ default: 'ColorSwitcher' })); +vi.mock('@/components/UnraidToaster.vue', () => ({ default: 'UnraidToaster' })); +vi.mock('../UpdateOs/TestUpdateModal.standalone.vue', () => ({ default: 'TestUpdateModal' })); +vi.mock('../TestThemeSwitcher.standalone.vue', () => ({ default: 'TestThemeSwitcher' })); + +describe('component-registry', () => { + it('should export ComponentMapping type', async () => { + const module = await import('~/components/Wrapper/component-registry'); + expect(module).toBeDefined(); + }); + + it('should export componentMappings array', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + expect(Array.isArray(componentMappings)).toBe(true); + expect(componentMappings.length).toBeGreaterThan(0); + }); + + it('should have required properties for each component mapping', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + componentMappings.forEach((mapping) => { + expect(mapping).toHaveProperty('selector'); + expect(mapping).toHaveProperty('appId'); + expect(mapping).toHaveProperty('component'); + + // Check selector is string or array + expect(typeof mapping.selector === 'string' || Array.isArray(mapping.selector)).toBe(true); + + // Check appId is string + expect(typeof mapping.appId).toBe('string'); + + // Check component exists and is an object + expect(mapping.component).toBeDefined(); + expect(typeof mapping.component).toBe('object'); + }); + }); + + it('should have priority components listed first', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + // Priority components should be first + expect(componentMappings[0].appId).toBe('header-os-version'); + expect(componentMappings[1].appId).toBe('user-profile'); + }); + + it('should support multiple selectors for modals', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + const modalsMapping = componentMappings.find((m) => m.appId === 'modals'); + expect(Array.isArray(modalsMapping?.selector)).toBe(true); + expect(modalsMapping?.selector).toContain('unraid-modals'); + expect(modalsMapping?.selector).toContain('#modals'); + expect(modalsMapping?.selector).toContain('modals-direct'); + }); + + it('should support multiple selectors for api key components', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + const apiKeyMapping = componentMappings.find((m) => m.appId === 'apikey-page'); + expect(Array.isArray(apiKeyMapping?.selector)).toBe(true); + expect(apiKeyMapping?.selector).toContain('unraid-apikey-page'); + expect(apiKeyMapping?.selector).toContain('unraid-api-key-manager'); + }); + + it('should support multiple selectors for toaster', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + const toasterMapping = componentMappings.find((m) => m.appId === 'toaster'); + expect(Array.isArray(toasterMapping?.selector)).toBe(true); + expect(toasterMapping?.selector).toContain('unraid-toaster'); + expect(toasterMapping?.selector).toContain('uui-toaster'); + }); + + it('should have unique appIds', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + const appIds = componentMappings.map((m) => m.appId); + const uniqueAppIds = new Set(appIds); + expect(appIds.length).toBe(uniqueAppIds.size); + }); + + it('should define all components as async components', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + componentMappings.forEach((mapping) => { + expect(mapping.component).toBeDefined(); + expect(typeof mapping.component).toBe('object'); + }); + }); + + it('should have at least the core component mappings', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + // Just ensure we have a reasonable number of components, not an exact count + expect(componentMappings.length).toBeGreaterThan(10); + }); + + it('should include all expected components', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + const expectedAppIds = [ + 'header-os-version', + 'user-profile', + 'auth', + 'connect-settings', + 'download-api-logs', + 'modals', + 'registration', + 'wan-ip-check', + 'callback-handler', + 'log-viewer', + 'sso-button', + 'welcome-modal', + 'update-os', + 'downgrade-os', + 'dev-settings', + 'apikey-page', + 'apikey-authorize', + 'dev-modal-test', + 'detail-test', + 'theme-switcher', + 'color-switcher', + 'toaster', + 'test-update-modal', + 'test-theme-switcher', + ]; + + const actualAppIds = componentMappings.map((m) => m.appId); + expectedAppIds.forEach((appId) => { + expect(actualAppIds).toContain(appId); + }); + }); + + it('should properly format selectors', async () => { + const { componentMappings } = await import('~/components/Wrapper/component-registry'); + + componentMappings.forEach((mapping) => { + if (typeof mapping.selector === 'string') { + // Single selectors should be non-empty strings + expect(mapping.selector.length).toBeGreaterThan(0); + } else if (Array.isArray(mapping.selector)) { + // Array selectors should have at least one item + expect(mapping.selector.length).toBeGreaterThan(0); + // Each selector in array should be non-empty string + mapping.selector.forEach((sel) => { + expect(typeof sel).toBe('string'); + expect(sel.length).toBeGreaterThan(0); + }); + } + }); + }); +}); diff --git a/web/__test__/components/Wrapper/mount-engine.test.ts b/web/__test__/components/Wrapper/mount-engine.test.ts index 72564d966..8868b0544 100644 --- a/web/__test__/components/Wrapper/mount-engine.test.ts +++ b/web/__test__/components/Wrapper/mount-engine.test.ts @@ -2,31 +2,33 @@ 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'; -import type { App as VueApp } from 'vue'; -// Extend HTMLElement to include Vue's internal properties (matching the source file) -interface HTMLElementWithVue extends HTMLElement { - __vueParentComponent?: { - appContext?: { - app?: VueApp; - }; - }; -} - -// We'll manually mock createApp only in specific tests that need it -vi.mock('vue', async () => { - const actual = await vi.importActual('vue'); - return { - ...actual, - }; -}); - -const mockEnsureTeleportContainer = vi.fn(); -vi.mock('@unraid/ui', () => ({ - ensureTeleportContainer: mockEnsureTeleportContainer, +// 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 mockI18n = { global: {}, install: vi.fn(), @@ -60,26 +62,22 @@ vi.mock('~/helpers/i18n-utils', () => ({ createHtmlEntityDecoder: vi.fn(() => (str: string) => str), })); -// CSS is now bundled separately by Vite, no inline imports - describe('mount-engine', () => { - let mountVueApp: typeof import('~/components/Wrapper/mount-engine').mountVueApp; - let unmountVueApp: typeof import('~/components/Wrapper/mount-engine').unmountVueApp; - let getMountedApp: typeof import('~/components/Wrapper/mount-engine').getMountedApp; - let autoMountComponent: typeof import('~/components/Wrapper/mount-engine').autoMountComponent; + let mountUnifiedApp: typeof import('~/components/Wrapper/mount-engine').mountUnifiedApp; + let autoMountAllComponents: typeof import('~/components/Wrapper/mount-engine').autoMountAllComponents; let TestComponent: ReturnType; let consoleWarnSpy: MockInstance; let consoleErrorSpy: MockInstance; - let consoleInfoSpy: MockInstance; - let consoleDebugSpy: MockInstance; - let testContainer: HTMLDivElement; beforeEach(async () => { + // Clear component mappings + mockComponentMappings.length = 0; + + // Import fresh module + vi.resetModules(); const module = await import('~/components/Wrapper/mount-engine'); - mountVueApp = module.mountVueApp; - unmountVueApp = module.unmountVueApp; - getMountedApp = module.getMountedApp; - autoMountComponent = module.autoMountComponent; + mountUnifiedApp = module.mountUnifiedApp; + autoMountAllComponents = module.autoMountAllComponents; TestComponent = defineComponent({ name: 'TestComponent', @@ -96,526 +94,319 @@ describe('mount-engine', () => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); - consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - - testContainer = document.createElement('div'); - testContainer.id = 'test-container'; - document.body.appendChild(testContainer); vi.clearAllMocks(); - // Clear mounted apps from previous tests - if (window.mountedApps) { - window.mountedApps.clear(); - } + // Clean up DOM + document.body.innerHTML = ''; }); afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ''; - if (window.mountedApps) { - window.mountedApps.clear(); + // Clean up global references + if (window.__unifiedApp) { + delete window.__unifiedApp; + } + if (window.__mountedComponents) { + delete window.__mountedComponents; } }); - describe('mountVueApp', () => { - it('should mount a Vue app to a single element', () => { + 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 = 'app'; + element.id = 'test-app'; document.body.appendChild(element); - const app = mountVueApp({ + mockComponentMappings.push({ + selector: '#test-app', + appId: 'test-app', component: TestComponent, - selector: '#app', }); + const app = mountUnifiedApp(); + expect(app).toBeTruthy(); - expect(element.querySelector('.test-component')).toBeTruthy(); - expect(element.textContent).toBe('Hello'); - expect(mockEnsureTeleportContainer).toHaveBeenCalled(); - }); + expect(mockI18n.install).toHaveBeenCalled(); + expect(mockGlobalPinia.install).toHaveBeenCalled(); - it('should mount with custom props', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); - - const app = mountVueApp({ - component: TestComponent, - selector: '#app', - props: { message: 'Custom Message' }, + // Wait for async component to render + await vi.waitFor(() => { + expect(element.querySelector('.test-component')).toBeTruthy(); }); - expect(app).toBeTruthy(); - expect(element.textContent).toBe('Custom Message'); + // 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', () => { + it('should parse props from element attributes', async () => { const element = document.createElement('div'); - element.id = 'app'; + element.id = 'test-app'; element.setAttribute('message', 'Attribute Message'); document.body.appendChild(element); - const app = mountVueApp({ + mockComponentMappings.push({ + selector: '#test-app', + appId: 'test-app', component: TestComponent, - selector: '#app', }); - expect(app).toBeTruthy(); - expect(element.textContent).toBe('Attribute Message'); + mountUnifiedApp(); + + // Wait for async component to render + await vi.waitFor(() => { + expect(element.textContent).toContain('Attribute Message'); + }); }); - it('should parse JSON props from attributes', () => { + it('should handle JSON props from attributes', () => { const element = document.createElement('div'); - element.id = 'app'; + element.id = 'test-app'; element.setAttribute('message', '{"text": "JSON Message"}'); document.body.appendChild(element); - const app = mountVueApp({ + mockComponentMappings.push({ + selector: '#test-app', + appId: 'test-app', component: TestComponent, - selector: '#app', }); - expect(app).toBeTruthy(); + mountUnifiedApp(); + + // The component receives the parsed JSON object expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}'); }); it('should handle HTML-encoded JSON in attributes', () => { const element = document.createElement('div'); - element.id = 'app'; + element.id = 'test-app'; element.setAttribute('message', '{"text": "Encoded"}'); document.body.appendChild(element); - const app = mountVueApp({ + mockComponentMappings.push({ + selector: '#test-app', + appId: 'test-app', component: TestComponent, - selector: '#app', }); - expect(app).toBeTruthy(); + mountUnifiedApp(); + expect(element.getAttribute('message')).toBe('{"text": "Encoded"}'); }); - it('should mount to multiple elements', () => { + it('should handle multiple selector aliases', async () => { const element1 = document.createElement('div'); - element1.className = 'multi-mount'; + element1.id = 'app1'; document.body.appendChild(element1); const element2 = document.createElement('div'); - element2.className = 'multi-mount'; + element2.className = 'app-alt'; document.body.appendChild(element2); - const app = mountVueApp({ + // Component with multiple selector aliases - only first match mounts + mockComponentMappings.push({ + selector: ['#app1', '.app-alt'], + appId: 'multi-selector', component: TestComponent, - selector: '.multi-mount', }); - expect(app).toBeTruthy(); - expect(element1.querySelector('.test-component')).toBeTruthy(); - expect(element2.querySelector('.test-component')).toBeTruthy(); + 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 use shadow root when specified', () => { + it('should handle async component loaders', async () => { const element = document.createElement('div'); - element.id = 'shadow-app'; + element.id = 'async-app'; document.body.appendChild(element); - const app = mountVueApp({ + mockComponentMappings.push({ + selector: '#async-app', + appId: 'async-app', component: TestComponent, - selector: '#shadow-app', - useShadowRoot: true, }); - expect(app).toBeTruthy(); - expect(element.shadowRoot).toBeTruthy(); - expect(element.shadowRoot?.querySelector('#app')).toBeTruthy(); - expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy(); + mountUnifiedApp(); + + // Wait for component to mount + await vi.waitFor(() => { + expect(element.querySelector('.test-component')).toBeTruthy(); + }); }); - it('should clean up existing Vue attributes', () => { + it('should skip already mounted elements', () => { const element = document.createElement('div'); - element.id = 'app'; + element.id = 'already-mounted'; element.setAttribute('data-vue-mounted', 'true'); - element.setAttribute('data-v-app', ''); - element.setAttribute('data-server-rendered', 'true'); - element.setAttribute('data-v-123', ''); document.body.appendChild(element); - mountVueApp({ + mockComponentMappings.push({ + selector: '#already-mounted', + appId: 'already-mounted', component: TestComponent, - selector: '#app', }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[VueMountApp] Element #app has Vue attributes but no content, cleaning up' - ); + mountUnifiedApp(); + + // Should not mount to already mounted element + expect(element.querySelector('.test-component')).toBeFalsy(); }); - it('should handle elements with problematic child nodes', () => { - const element = document.createElement('div'); - element.id = 'app'; - element.appendChild(document.createTextNode(' ')); - element.appendChild(document.createComment('test comment')); - document.body.appendChild(element); - - const app = mountVueApp({ - component: TestComponent, - selector: '#app', - }); - - expect(app).toBeTruthy(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[VueMountApp] Cleaning up problematic nodes in #app before mounting' - ); - }); - - it('should return null when no elements found', () => { - const app = mountVueApp({ - component: TestComponent, + it('should handle missing elements gracefully', () => { + mockComponentMappings.push({ selector: '#non-existent', + appId: 'non-existent', + component: TestComponent, }); - expect(app).toBeNull(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[VueMountApp] No elements found for any selector: #non-existent' + const app = 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); + + mountUnifiedApp(); + + // Should log error for missing component + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[UnifiedMount] No component defined for invalid-app' ); - }); - - it('should add unapi class to mounted elements', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#app', - }); - - expect(element.classList.contains('unapi')).toBe(true); - expect(element.getAttribute('data-vue-mounted')).toBe('true'); - }); - - it('should skip disconnected elements during multi-mount', () => { - const element1 = document.createElement('div'); - element1.className = 'multi'; - document.body.appendChild(element1); - - const element2 = document.createElement('div'); - element2.className = 'multi'; - // This element is NOT added to the document - - // Create a third element and manually add it to element1 to simulate DOM issues - const orphanedChild = document.createElement('span'); - element1.appendChild(orphanedChild); - // Now remove element1 from DOM temporarily to trigger the warning - element1.remove(); - - // Add element1 back - document.body.appendChild(element1); - - // Create elements matching the selector - document.body.innerHTML = ''; - const validElement = document.createElement('div'); - validElement.className = 'multi'; - document.body.appendChild(validElement); - - const disconnectedElement = document.createElement('div'); - disconnectedElement.className = 'multi'; - const container = document.createElement('div'); - container.appendChild(disconnectedElement); - // Now disconnectedElement has a parent but that parent is not in the document - - const app = mountVueApp({ - component: TestComponent, - selector: '.multi', - }); - - expect(app).toBeTruthy(); - // The app should mount only to the connected element - expect(validElement.querySelector('.test-component')).toBeTruthy(); - }); - }); - - describe('unmountVueApp', () => { - it('should unmount a mounted app', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); - - const app = mountVueApp({ - component: TestComponent, - selector: '#app', - appId: 'test-app', - }); - - expect(app).toBeTruthy(); - expect(getMountedApp('test-app')).toBe(app); - - const result = unmountVueApp('test-app'); - expect(result).toBe(true); - expect(getMountedApp('test-app')).toBeUndefined(); - }); - - it('should clean up data attributes on unmount', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#app', - appId: 'test-app', - }); - - expect(element.getAttribute('data-vue-mounted')).toBe('true'); - expect(element.classList.contains('unapi')).toBe(true); - - unmountVueApp('test-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 unmount cloned apps', () => { + it('should create hidden root element if not exists', () => { + mountUnifiedApp(); + + const rootElement = document.getElementById('unraid-unified-root'); + expect(rootElement).toBeTruthy(); + expect(rootElement?.style.display).toBe('none'); + }); + + it('should reuse existing root element', () => { + // Create root element first + const existingRoot = document.createElement('div'); + existingRoot.id = 'unraid-unified-root'; + document.body.appendChild(existingRoot); + + 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, + }); + + 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.className = 'multi'; + element1.id = 'app1'; document.body.appendChild(element1); const element2 = document.createElement('div'); - element2.className = 'multi'; + element2.id = 'app2'; document.body.appendChild(element2); - mountVueApp({ - component: TestComponent, - selector: '.multi', - appId: 'multi-app', - }); - - const result = unmountVueApp('multi-app'); - expect(result).toBe(true); - }); - - it('should remove shadow root containers', () => { - const element = document.createElement('div'); - element.id = 'shadow-app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#shadow-app', - appId: 'shadow-app', - useShadowRoot: true, - }); - - expect(element.shadowRoot?.querySelector('#app')).toBeTruthy(); - - unmountVueApp('shadow-app'); - - expect(element.shadowRoot?.querySelector('#app')).toBeFalsy(); - }); - - it('should warn when unmounting non-existent app', () => { - const result = unmountVueApp('non-existent'); - expect(result).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] No app found with id: non-existent'); - }); - - it('should handle unmount errors gracefully', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); - - const app = mountVueApp({ - component: TestComponent, - selector: '#app', - appId: 'test-app', - }); - - // Force an error by corrupting the app - if (app) { - (app as { unmount: () => void }).unmount = () => { - throw new Error('Unmount error'); - }; - } - - const result = unmountVueApp('test-app'); - expect(result).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[VueMountApp] Error unmounting app test-app:', - expect.any(Error) + mockComponentMappings.push( + { + selector: '#app1', + appId: 'app1', + component: TestComponent, + }, + { + selector: '#app2', + appId: 'app2', + component: TestComponent, + } ); - }); - }); - describe('getMountedApp', () => { - it('should return mounted app by id', () => { - const element = document.createElement('div'); - element.id = 'app'; - document.body.appendChild(element); + mountUnifiedApp(); - const app = mountVueApp({ - component: TestComponent, - selector: '#app', - appId: 'test-app', + // Wait for async components to render + await vi.waitFor(() => { + expect(element1.querySelector('.test-component')).toBeTruthy(); + expect(element2.querySelector('.test-component')).toBeTruthy(); }); - expect(getMountedApp('test-app')).toBe(app); - }); - - it('should return undefined for non-existent app', () => { - expect(getMountedApp('non-existent')).toBeUndefined(); + // 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('autoMountComponent', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should auto-mount when DOM is ready', async () => { + describe('autoMountAllComponents', () => { + it('should call mountUnifiedApp', async () => { const element = document.createElement('div'); element.id = 'auto-app'; document.body.appendChild(element); - autoMountComponent(TestComponent, '#auto-app'); - - await vi.runAllTimersAsync(); - - expect(element.querySelector('.test-component')).toBeTruthy(); - }); - - it('should wait for DOMContentLoaded if document is loading', async () => { - Object.defineProperty(document, 'readyState', { - value: 'loading', - writable: true, - }); - - const element = document.createElement('div'); - element.id = 'auto-app'; - document.body.appendChild(element); - - autoMountComponent(TestComponent, '#auto-app'); - - expect(element.querySelector('.test-component')).toBeFalsy(); - - Object.defineProperty(document, 'readyState', { - value: 'complete', - writable: true, - }); - - document.dispatchEvent(new Event('DOMContentLoaded')); - await vi.runAllTimersAsync(); - - expect(element.querySelector('.test-component')).toBeTruthy(); - }); - - it('should skip auto-mount for already mounted modals', async () => { - const element1 = document.createElement('div'); - element1.id = 'modals'; - document.body.appendChild(element1); - - mountVueApp({ + mockComponentMappings.push({ + selector: '#auto-app', + appId: 'auto-app', component: TestComponent, - selector: '#modals', - appId: 'modals', }); - autoMountComponent(TestComponent, '#modals'); - await vi.runAllTimersAsync(); + autoMountAllComponents(); - expect(consoleDebugSpy).toHaveBeenCalledWith( - '[VueMountApp] Component already mounted as modals for selector #modals, returning existing instance' - ); - }); - - it('should mount immediately for all selectors', async () => { - const element = document.createElement('div'); - element.id = 'unraid-connect-settings'; - document.body.appendChild(element); - - autoMountComponent(TestComponent, '#unraid-connect-settings'); - - // Component should mount immediately without delay - await vi.runAllTimersAsync(); - expect(element.querySelector('.test-component')).toBeTruthy(); - }); - - it('should mount even when element is hidden', async () => { - const element = document.createElement('div'); - element.id = 'hidden-app'; - element.style.display = 'none'; - document.body.appendChild(element); - - autoMountComponent(TestComponent, '#hidden-app'); - await vi.runAllTimersAsync(); - - // Hidden elements should still mount successfully - expect(element.querySelector('.test-component')).toBeTruthy(); - expect(consoleWarnSpy).not.toHaveBeenCalledWith( - expect.stringContaining('No valid DOM elements found') - ); - }); - - it('should handle nextSibling errors with retry', async () => { - const element = document.createElement('div'); - element.id = 'error-app'; - element.setAttribute('data-vue-mounted', 'true'); - element.setAttribute('data-v-app', ''); - document.body.appendChild(element); - - // Simulate the element having Vue instance references which cause nextSibling errors - const mockVueInstance = { appContext: { app: {} as VueApp } }; - (element as HTMLElementWithVue).__vueParentComponent = mockVueInstance; - - // Add an invalid child that will trigger cleanup - const textNode = document.createTextNode(' '); - element.appendChild(textNode); - - autoMountComponent(TestComponent, '#error-app'); - await vi.runAllTimersAsync(); - - // Should detect and clean up existing Vue state - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - '[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up' - ) - ); - - // Should successfully mount after cleanup - expect(element.querySelector('.test-component')).toBeTruthy(); - }); - - it('should pass options to mountVueApp', async () => { - const element = document.createElement('div'); - element.id = 'options-app'; - document.body.appendChild(element); - - autoMountComponent(TestComponent, '#options-app', { - props: { message: 'Auto Mount Message' }, - useShadowRoot: true, + // Wait for async component to render + await vi.waitFor(() => { + expect(element.querySelector('.test-component')).toBeTruthy(); }); - - await vi.runAllTimersAsync(); - - expect(element.shadowRoot).toBeTruthy(); - expect(element.shadowRoot?.textContent).toContain('Auto Mount Message'); }); }); describe('i18n setup', () => { it('should setup i18n with default locale', () => { - const element = document.createElement('div'); - element.id = 'i18n-app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#i18n-app', - }); - + mountUnifiedApp(); expect(mockI18n.install).toHaveBeenCalled(); }); @@ -627,14 +418,7 @@ describe('mount-engine', () => { JSON.stringify(localeData) ); - const element = document.createElement('div'); - element.id = 'i18n-app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#i18n-app', - }); + mountUnifiedApp(); delete (window as unknown as Record).LOCALE_DATA; }); @@ -642,14 +426,7 @@ describe('mount-engine', () => { it('should handle locale data parsing errors', () => { (window as unknown as Record).LOCALE_DATA = 'invalid json'; - const element = document.createElement('div'); - element.id = 'i18n-app'; - document.body.appendChild(element); - - mountVueApp({ - component: TestComponent, - selector: '#i18n-app', - }); + mountUnifiedApp(); expect(consoleErrorSpy).toHaveBeenCalledWith( '[VueMountApp] error parsing messages', @@ -660,60 +437,28 @@ describe('mount-engine', () => { }); }); - describe('error recovery', () => { - it('should attempt recovery from nextSibling error', async () => { - vi.useFakeTimers(); + describe('global exposure', () => { + it('should expose unified app globally', () => { + const app = mountUnifiedApp(); + expect(window.__unifiedApp).toBe(app); + }); + it('should expose mounted components globally', () => { const element = document.createElement('div'); - element.id = 'recovery-app'; + element.id = 'global-app'; document.body.appendChild(element); - // Create a mock Vue app that throws on first mount attempt - let mountAttempt = 0; - const mockApp = { - use: vi.fn().mockReturnThis(), - provide: vi.fn().mockReturnThis(), - mount: vi.fn().mockImplementation(() => { - mountAttempt++; - if (mountAttempt === 1) { - const error = new TypeError('Cannot read property nextSibling of null'); - throw error; - } - return mockApp; - }), - unmount: vi.fn(), - version: '3.0.0', - config: { globalProperties: {} }, - }; - - // Mock createApp using module mock - const vueModule = await import('vue'); - vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never); - - mountVueApp({ + mockComponentMappings.push({ + selector: '#global-app', + appId: 'global-app', component: TestComponent, - selector: '#recovery-app', - appId: 'recovery-app', }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[VueMountApp] Attempting recovery from nextSibling error for #recovery-app' - ); + mountUnifiedApp(); - await vi.advanceTimersByTimeAsync(10); - - expect(consoleInfoSpy).toHaveBeenCalledWith( - '[VueMountApp] Successfully recovered from nextSibling error for #recovery-app' - ); - - vi.useRealTimers(); - }); - }); - - describe('global exposure', () => { - it('should expose mountedApps globally', () => { - expect(window.mountedApps).toBeDefined(); - expect(window.mountedApps).toBeInstanceOf(Map); + expect(window.__mountedComponents).toBeDefined(); + expect(Array.isArray(window.__mountedComponents)).toBe(true); + expect(window.__mountedComponents!.length).toBe(1); }); it('should expose globalPinia globally', () => { @@ -721,4 +466,23 @@ describe('mount-engine', () => { expect(window.globalPinia).toBe(mockGlobalPinia); }); }); + + describe('performance debugging', () => { + it('should not log timing by default', () => { + const element = document.createElement('div'); + element.id = 'perf-app'; + document.body.appendChild(element); + + mockComponentMappings.push({ + selector: '#perf-app', + appId: 'perf-app', + component: TestComponent, + }); + + mountUnifiedApp(); + + // Should not log timing information when PERF_DEBUG is false + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted')); + }); + }); }); diff --git a/web/__test__/components/component-registry.test.ts b/web/__test__/components/component-registry.test.ts index 5dc7c6442..6cde6afc4 100644 --- a/web/__test__/components/component-registry.test.ts +++ b/web/__test__/components/component-registry.test.ts @@ -57,16 +57,12 @@ vi.mock('~/components/UnraidToaster.vue', () => ({ })); // Mock mount-engine module -const mockAutoMountComponent = vi.fn(); const mockAutoMountAllComponents = vi.fn(); -const mockMountVueApp = vi.fn(); -const mockGetMountedApp = vi.fn(); +const mockMountUnifiedApp = vi.fn(); vi.mock('~/components/Wrapper/mount-engine', () => ({ - autoMountComponent: mockAutoMountComponent, autoMountAllComponents: mockAutoMountAllComponents, - mountVueApp: mockMountVueApp, - getMountedApp: mockGetMountedApp, + mountUnifiedApp: mockMountUnifiedApp, })); // Mock theme initializer @@ -104,10 +100,7 @@ vi.mock('graphql', () => ({ })); // Mock @unraid/ui -const mockEnsureTeleportContainer = vi.fn(); -vi.mock('@unraid/ui', () => ({ - ensureTeleportContainer: mockEnsureTeleportContainer, -})); +vi.mock('@unraid/ui', () => ({})); describe('component-registry', () => { beforeEach(() => { @@ -151,10 +144,12 @@ describe('component-registry', () => { expect(mockInitializeTheme).toHaveBeenCalled(); }); - it('should ensure teleport container exists', async () => { + it('should mount unified app with components', async () => { await import('~/components/Wrapper/auto-mount'); - expect(mockEnsureTeleportContainer).toHaveBeenCalled(); + // The unified app architecture no longer requires teleport container setup per component + // Instead it uses a unified approach + expect(mockAutoMountAllComponents).toHaveBeenCalled(); }); }); @@ -188,26 +183,29 @@ describe('component-registry', () => { it('should expose utility functions globally', async () => { await import('~/components/Wrapper/auto-mount'); - expect(window.mountVueApp).toBe(mockMountVueApp); - expect(window.getMountedApp).toBe(mockGetMountedApp); - expect(window.autoMountComponent).toBe(mockAutoMountComponent); + // With unified app architecture, these are exposed instead: + expect(window.apolloClient).toBe(mockApolloClient); + expect(window.gql).toBe(mockParse); + expect(window.graphqlParse).toBe(mockParse); + // The unified app itself is exposed via window.__unifiedApp after mounting }); - it('should expose mountVueApp function globally', async () => { + it('should not expose legacy mount functions', async () => { await import('~/components/Wrapper/auto-mount'); - // Check that mountVueApp is exposed - expect(typeof window.mountVueApp).toBe('function'); - - // Note: Dynamic mount functions are no longer created automatically - // They would be created via mountVueApp calls + // These functions are no longer exposed in the unified app architecture + expect(window.mountVueApp).toBeUndefined(); + expect(window.getMountedApp).toBeUndefined(); + expect(window.autoMountComponent).toBeUndefined(); }); - it('should expose autoMountComponent function globally', async () => { + it('should expose apollo client and graphql utilities', async () => { await import('~/components/Wrapper/auto-mount'); - // Check that autoMountComponent is exposed - expect(typeof window.autoMountComponent).toBe('function'); + // Check that Apollo client and GraphQL utilities are exposed + expect(window.apolloClient).toBeDefined(); + expect(typeof window.gql).toBe('function'); + expect(typeof window.graphqlParse).toBe('function'); }); }); }); diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 35e8fc16b..0a2cd58a1 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -103,6 +103,7 @@ export default [ parser: tseslint.parser, parserOptions: { ...commonLanguageOptions, + tsconfigRootDir: import.meta.dirname, ecmaFeatures: { jsx: true, }, @@ -128,6 +129,7 @@ export default [ parserOptions: { ...commonLanguageOptions, parser: tseslint.parser, + tsconfigRootDir: import.meta.dirname, ecmaFeatures: { jsx: true, }, diff --git a/web/scripts/deploy-dev.sh b/web/scripts/deploy-dev.sh index 6699fd6e0..1e740264d 100755 --- a/web/scripts/deploy-dev.sh +++ b/web/scripts/deploy-dev.sh @@ -40,7 +40,7 @@ if [ "$has_standalone" = true ]; then rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/" standalone_exit_code=$? # If standalone rsync failed, update exit_code - if [ $standalone_exit_code -ne 0 ]; then + if [ "$standalone_exit_code" -ne 0 ]; then exit_code=$standalone_exit_code fi fi @@ -49,7 +49,9 @@ fi update_auth_request() { local server_name="$1" # SSH into server and update auth-request.php - ssh "root@${server_name}" bash -s << 'EOF' + ssh "root@${server_name}" /bin/bash -s << 'EOF' + set -euo pipefail + set -o errtrace AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php' UNRAID_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/' @@ -76,8 +78,9 @@ update_auth_request() { # Clean up any existing temp file rm -f "$TEMP_FILE" - # Process the file through both stages using a pipeline + # Process the file through both stages # First remove existing web component entries, then add new ones + # Use a simpler approach without relying on PIPESTATUS awk ' BEGIN { in_array = 0 } /\$arrWhitelist\s*=\s*\[/ { @@ -93,19 +96,40 @@ update_auth_request() { !in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/.*\.(m?js|css)/ { print $0 } - ' "$AUTH_REQUEST_FILE" | \ + ' "$AUTH_REQUEST_FILE" > "$TEMP_FILE.stage1" || { + echo "Failed to process $AUTH_REQUEST_FILE (stage 1)" >&2 + rm -f "$TEMP_FILE.stage1" + exit 1 + } + awk -v files_to_add="$(printf '%s\n' "${FILES_TO_ADD[@]}" | sed "s/'/\\\\'/g" | sort -u | awk '{printf " \047%s\047,\n", $0}')" ' - /\$arrWhitelist\s*=\s*\[/ { + /\$arrWhitelist[[:space:]]*=[[:space:]]*\[/ { print $0 print files_to_add next } { print } - ' > "$TEMP_FILE" - - # Check pipeline succeeded and temp file is non-empty - if [ ${PIPESTATUS[0]} -ne 0 ] || [ ${PIPESTATUS[1]} -ne 0 ] || [ ! -s "$TEMP_FILE" ]; then - echo "Failed to process $AUTH_REQUEST_FILE" >&2 + ' "$TEMP_FILE.stage1" > "$TEMP_FILE" || { + echo "Failed to process $AUTH_REQUEST_FILE (stage 2)" >&2 + rm -f "$TEMP_FILE.stage1" "$TEMP_FILE" + exit 1 + } + + # Clean up intermediate file + rm -f "$TEMP_FILE.stage1" + + # Verify whitelist entries were actually injected + if [ ${#FILES_TO_ADD[@]} -gt 0 ]; then + if ! grep -qF "${FILES_TO_ADD[0]}" "$TEMP_FILE"; then + echo "Failed to inject whitelist entries" >&2 + rm -f "$TEMP_FILE" + exit 1 + fi + fi + + # Check temp file is non-empty + if [ ! -s "$TEMP_FILE" ]; then + echo "Failed to process $AUTH_REQUEST_FILE - empty result" >&2 rm -f "$TEMP_FILE" exit 1 fi @@ -136,7 +160,7 @@ update_auth_request "$server_name" auth_request_exit_code=$? # If auth request update failed, update exit_code -if [ $auth_request_exit_code -ne 0 ]; then +if [ "$auth_request_exit_code" -ne 0 ]; then exit_code=$auth_request_exit_code fi diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 97a1fb16e..4d116fa46 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -87,6 +87,7 @@ .unapi p { margin: 0; padding: 0; + text-align: unset; } /* Reset UL styles to prevent default browser styling */ @@ -143,6 +144,13 @@ /* Note: Tailwind utilities will apply globally but should be used with .unapi prefix in HTML */ +/* Ensure unraid-modals container has extremely high z-index */ +unraid-modals.unapi { + position: relative; + z-index: 999999; + isolation: isolate; +} + /* Style for Unraid progress frame */ iframe#progressFrame { background-color: var(--background-color); diff --git a/web/src/components/HeaderOsVersion.standalone.vue b/web/src/components/HeaderOsVersion.standalone.vue index 26dd7857e..5d0fc422d 100644 --- a/web/src/components/HeaderOsVersion.standalone.vue +++ b/web/src/components/HeaderOsVersion.standalone.vue @@ -1,8 +1,8 @@