mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
10 Commits
v4.22.1
...
4.22.1-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771014b005 | ||
|
|
31a255c928 | ||
|
|
167857a323 | ||
|
|
b80988aaab | ||
|
|
fe4a6451f1 | ||
|
|
9a86c615da | ||
|
|
25ff8992a5 | ||
|
|
45fb53d040 | ||
|
|
c855caa9b2 | ||
|
|
ba4a43aec8 |
23
.github/workflows/build-plugin.yml
vendored
23
.github/workflows/build-plugin.yml
vendored
@@ -51,21 +51,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get API Version
|
||||
id: vars
|
||||
@@ -76,14 +71,6 @@ jobs:
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ github.workspace }}
|
||||
|
||||
11
.github/workflows/deploy-storybook.yml
vendored
11
.github/workflows/deploy-storybook.yml
vendored
@@ -22,16 +22,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
|
||||
96
.github/workflows/main.yml
vendored
96
.github/workflows/main.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -23,10 +27,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -34,25 +44,6 @@ jobs:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
|
||||
version: 1.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: PNPM Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -175,29 +166,16 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -228,7 +206,7 @@ jobs:
|
||||
id: buildnumber
|
||||
uses: onyxmueller/build-tag-number@v1
|
||||
with:
|
||||
token: ${{secrets.github_token}}
|
||||
token: ${{secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN}}
|
||||
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
|
||||
|
||||
- name: Build
|
||||
@@ -252,29 +230,16 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -318,29 +283,16 @@ jobs:
|
||||
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
|
||||
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: PNPM Install
|
||||
run: |
|
||||
|
||||
@@ -29,11 +29,6 @@ jobs:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Set Timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: "America/Los_Angeles"
|
||||
|
||||
- name: Set PR number
|
||||
id: pr_number
|
||||
run: |
|
||||
|
||||
9
.github/workflows/release-production.yml
vendored
9
.github/workflows/release-production.yml
vendored
@@ -28,9 +28,14 @@ jobs:
|
||||
with:
|
||||
latest: true
|
||||
prerelease: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
- run: |
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.19.1",
|
||||
"version": "4.22.1",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/helmet": "13.0.1",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-codegen/client-preset": "5.0.0",
|
||||
"@graphql-tools/load-files": "7.0.1",
|
||||
"@graphql-tools/merge": "9.1.1",
|
||||
"@graphql-tools/schema": "10.0.25",
|
||||
@@ -103,7 +103,7 @@
|
||||
"execa": "9.6.0",
|
||||
"exit-hook": "4.0.0",
|
||||
"fastify": "5.5.0",
|
||||
"filenamify": "6.0.0",
|
||||
"filenamify": "7.0.0",
|
||||
"fs-extra": "11.3.1",
|
||||
"glob": "11.0.3",
|
||||
"global-agent": "3.0.0",
|
||||
@@ -156,14 +156,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
"@graphql-codegen/add": "6.0.0",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-codegen/fragment-matcher": "6.0.0",
|
||||
"@graphql-codegen/import-types-preset": "3.0.1",
|
||||
"@graphql-codegen/typed-document-node": "5.1.2",
|
||||
"@graphql-codegen/typescript": "4.1.6",
|
||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.1",
|
||||
"@graphql-codegen/typed-document-node": "6.0.0",
|
||||
"@graphql-codegen/typescript": "5.0.0",
|
||||
"@graphql-codegen/typescript-operations": "5.0.0",
|
||||
"@graphql-codegen/typescript-resolvers": "5.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
@@ -205,7 +205,7 @@
|
||||
"rollup-plugin-node-externals": "8.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"tsx": "4.20.5",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.41.0",
|
||||
"unplugin-swc": "1.5.7",
|
||||
|
||||
350
api/src/unraid-api/rest/rest.service.test.ts
Normal file
350
api/src/unraid-api/rest/rest.service.test.ts
Normal file
@@ -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>(RestService);
|
||||
apiReportService = module.get<ApiReportService>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReadStream> {
|
||||
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');
|
||||
|
||||
13
package.json
13
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"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"devDependencies": {
|
||||
"@apollo/client": "3.14.0",
|
||||
"@faker-js/faker": "10.0.0",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@jsonforms/core": "3.6.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "6.0.1",
|
||||
"rxjs": "7.8.2",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"undici": "7.15.0",
|
||||
"vitest": "3.2.4",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"nest-authz": "2.17.0",
|
||||
"pify": "6.1.0",
|
||||
"rimraf": "6.0.1",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "3.2.4",
|
||||
"ws": "8.18.3"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
"conventional-changelog": "7.1.1",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"conventional-changelog-conventionalcommits": "9.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"glob": "11.0.3",
|
||||
"html-sloppy-escaper": "0.1.0",
|
||||
@@ -33,8 +33,9 @@
|
||||
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
|
||||
"env:clean": "rm -f .env",
|
||||
"// Testing": "",
|
||||
"test": "vitest && pnpm run test:extractor",
|
||||
"test:extractor": "bash ./tests/test-extractor.sh"
|
||||
"test": "vitest && pnpm run test:extractor && pnpm run test:shell-detection",
|
||||
"test:extractor": "bash ./tests/test-extractor.sh",
|
||||
"test:shell-detection": "bash ./tests/test-shell-detection.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"http-server": "14.1.1",
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
check_shell() {
|
||||
# This script runs with #!/bin/bash shebang
|
||||
# On Unraid, users may configure bash to load other shells through .bashrc
|
||||
# We check if the current process ($$) is actually bash, not another shell
|
||||
# Using $$ is correct here - we need to detect if THIS process is running the expected bash
|
||||
# We need to check if the interpreter running this script is actually bash
|
||||
# Use readlink on /proc to find the actual interpreter, not the script name
|
||||
local current_shell
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
|
||||
|
||||
# Get the actual interpreter from /proc
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
current_shell=$(readlink "/proc/$$/exe")
|
||||
else
|
||||
# Fallback to checking the current process if /proc isn't available
|
||||
# Note: This may return the script name on some systems
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
fi
|
||||
|
||||
# Remove any path and get just the shell name
|
||||
current_shell=$(basename "$current_shell")
|
||||
|
||||
|
||||
159
plugin/tests/test-shell-detection.sh
Executable file
159
plugin/tests/test-shell-detection.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
# Test script for shell detection logic in verify_install.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFY_SCRIPT="$SCRIPT_DIR/../source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test counter
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
|
||||
# Helper function to run a test
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_cmd="$2"
|
||||
local expected_result="$3"
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN + 1))
|
||||
|
||||
echo -n "Testing: $test_name ... "
|
||||
|
||||
# Run the test and capture exit code
|
||||
set +e
|
||||
output=$($test_cmd 2>&1)
|
||||
result=$?
|
||||
set -e
|
||||
|
||||
if [ "$result" -eq "$expected_result" ]; then
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
echo " Expected exit code: $expected_result, Got: $result"
|
||||
echo " Output: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
# Extract just the check_shell function from verify_install.sh
|
||||
extract_check_shell() {
|
||||
cat << 'EOF'
|
||||
#!/bin/bash
|
||||
check_shell() {
|
||||
# This script runs with #!/bin/bash shebang
|
||||
# On Unraid, users may configure bash to load other shells through .bashrc
|
||||
# We need to check if the interpreter running this script is actually bash
|
||||
# Use readlink on /proc to find the actual interpreter, not the script name
|
||||
local current_shell
|
||||
|
||||
# Get the actual interpreter from /proc
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
current_shell=$(readlink "/proc/$$/exe")
|
||||
else
|
||||
# Fallback to checking the current process if /proc isn't available
|
||||
# Note: This may return the script name on some systems
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
fi
|
||||
|
||||
# Remove any path and get just the shell name
|
||||
current_shell=$(basename "$current_shell")
|
||||
|
||||
if [[ "$current_shell" != "bash" ]]; then
|
||||
echo "Unsupported shell detected: $current_shell" >&2
|
||||
echo "Unraid scripts require bash but your system is configured to use $current_shell for scripts." >&2
|
||||
echo "This can cause infinite loops or unexpected behavior when Unraid scripts execute." >&2
|
||||
echo "Please configure $current_shell to only activate for interactive shells." >&2
|
||||
echo "Add this check to your ~/.bashrc or /etc/profile before starting $current_shell:" >&2
|
||||
echo " [[ \$- == *i* ]] && exec $current_shell" >&2
|
||||
echo "This ensures $current_shell only starts for interactive sessions, not scripts." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
check_shell
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "=== Shell Detection Tests ==="
|
||||
echo
|
||||
|
||||
# Test 1: Running with bash should succeed
|
||||
echo "Test 1: Direct bash execution"
|
||||
TEMP_SCRIPT=$(mktemp)
|
||||
extract_check_shell > "$TEMP_SCRIPT"
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
run_test "Bash interpreter (should pass)" "bash $TEMP_SCRIPT" 0
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
|
||||
# Test 2: Check that the actual verify_install.sh script works with bash
|
||||
echo "Test 2: Verify install script with bash"
|
||||
if [ -f "$VERIFY_SCRIPT" ]; then
|
||||
# Create a modified version that only runs check_shell
|
||||
TEMP_VERIFY=$(mktemp)
|
||||
sed -n '1,/^check_shell$/p' "$VERIFY_SCRIPT" > "$TEMP_VERIFY"
|
||||
echo "exit 0" >> "$TEMP_VERIFY"
|
||||
chmod +x "$TEMP_VERIFY"
|
||||
run_test "Verify install script shell check" "bash $TEMP_VERIFY" 0
|
||||
rm -f "$TEMP_VERIFY"
|
||||
else
|
||||
echo -e "${YELLOW}SKIP${NC} - verify_install.sh not found"
|
||||
fi
|
||||
|
||||
# Test 3: Simulate non-bash shell (if available)
|
||||
echo "Test 3: Non-bash shell simulation"
|
||||
if command -v sh >/dev/null 2>&1 && [ "$(readlink -f "$(command -v sh)")" != "$(readlink -f "$(command -v bash)")" ]; then
|
||||
TEMP_SCRIPT=$(mktemp)
|
||||
# Create a test that will fail if sh is detected
|
||||
cat << 'EOF' > "$TEMP_SCRIPT"
|
||||
#!/bin/sh
|
||||
# This simulates what would happen if a non-bash shell was detected
|
||||
current_shell=$(basename "$(readlink -f /proc/$$/exe 2>/dev/null || echo sh)")
|
||||
if [ "$current_shell" != "bash" ]; then
|
||||
echo "Detected non-bash shell: $current_shell" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
run_test "Non-bash shell detection" "sh $TEMP_SCRIPT" 1
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
else
|
||||
echo -e "${YELLOW}SKIP${NC} - sh not available or is symlinked to bash"
|
||||
fi
|
||||
|
||||
# Test 4: Check /proc availability (informational only, not a failure)
|
||||
echo "Test 4: /proc filesystem check"
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
echo -e "${GREEN}INFO${NC} - /proc filesystem is available"
|
||||
else
|
||||
echo -e "${YELLOW}INFO${NC} - /proc filesystem not available, fallback to ps will be used"
|
||||
fi
|
||||
|
||||
# Test 5: Verify the script name is not detected as shell
|
||||
echo "Test 5: Script name not detected as shell"
|
||||
TEMP_SCRIPT=$(mktemp -t verify_install.XXXXXX)
|
||||
extract_check_shell > "$TEMP_SCRIPT"
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
# This should pass because it's still bash, even though the script is named verify_install
|
||||
run_test "Script named verify_install (should still pass)" "bash $TEMP_SCRIPT" 0
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
|
||||
echo
|
||||
echo "=== Test Summary ==="
|
||||
echo "Tests run: $TESTS_RUN"
|
||||
echo "Tests passed: $TESTS_PASSED"
|
||||
echo "Tests failed: $((TESTS_RUN - TESTS_PASSED))"
|
||||
|
||||
if [ "$TESTS_PASSED" -eq "$TESTS_RUN" ]; then
|
||||
echo -e "${GREEN}All tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some tests failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
639
pnpm-lock.yaml
generated
639
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
|
||||
169
unraid-ui/src/composables/useTeleport.test.ts
Normal file
169
unraid-ui/src/composables/useTeleport.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string | HTMLElement>('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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -51,10 +51,6 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.copy.vue",
|
||||
"**/*copy.vue",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx"
|
||||
"**/*copy.vue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -31,8 +42,6 @@ export default function createConfig() {
|
||||
external: [
|
||||
'vue',
|
||||
'tailwindcss',
|
||||
'ajv',
|
||||
'ajv-errors',
|
||||
...(process.env.npm_lifecycle_script?.includes('storybook') ? [/^storybook\//] : []),
|
||||
],
|
||||
input: {
|
||||
@@ -77,6 +86,9 @@ export default function createConfig() {
|
||||
'@/theme': resolve(__dirname, './src/theme'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['ajv', 'ajv-errors'],
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
232
web/__test__/components/Modals.test.ts
Normal file
232
web/__test__/components/Modals.test.ts
Normal file
@@ -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: '<div>ActivationModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsChangelogModal',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">ChangelogModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsCheckUpdateResponseModal',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">CheckUpdateResponseModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcCallbackFeedback',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">CallbackFeedback</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UserProfile/Trial.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcTrial',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">Trial</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
184
web/__test__/components/Wrapper/component-registry.test.ts
Normal file
184
web/__test__/components/Wrapper/component-registry.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('vue')>('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<typeof defineComponent>;
|
||||
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<string, unknown>).LOCALE_DATA;
|
||||
});
|
||||
@@ -642,14 +426,7 @@ describe('mount-engine', () => {
|
||||
it('should handle locale data parsing errors', () => {
|
||||
(window as unknown as Record<string, unknown>).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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-codegen/introspection": "4.0.3",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-codegen/client-preset": "5.0.0",
|
||||
"@graphql-codegen/introspection": "5.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@pinia/testing": "1.0.2",
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Standalone Vue Apps Test Page</title>
|
||||
<title>Component Mounting Test - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
@@ -171,12 +173,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mock configurations for local testing -->
|
||||
<!-- Configuration for local testing -->
|
||||
<script>
|
||||
// Set GraphQL endpoint directly to API server
|
||||
// Change this to match your API server port
|
||||
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
|
||||
|
||||
// Set GraphQL endpoint - handled by Vite proxy in dev mode
|
||||
window.GRAPHQL_ENDPOINT = window.location.port === '3000' ? '/graphql' : 'http://localhost:3001/graphql';
|
||||
|
||||
// Mock webGui path for images
|
||||
window.__WEBGUI_PATH__ = '';
|
||||
|
||||
@@ -203,29 +204,30 @@
|
||||
|
||||
// Check for Vue app mounting
|
||||
let checkInterval = setInterval(() => {
|
||||
const mountedElements = document.querySelectorAll('unraid-header-os-version');
|
||||
let mountedCount = 0;
|
||||
|
||||
mountedElements.forEach(el => {
|
||||
if (el.innerHTML.trim() !== '') {
|
||||
mountedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const mountedElements = document.querySelectorAll('[data-vue-mounted="true"]');
|
||||
let totalComponents = document.querySelectorAll('unraid-header-os-version, unraid-modals').length;
|
||||
let mountedCount = mountedElements.length;
|
||||
|
||||
if (mountedCount > 0) {
|
||||
status.className = 'status success';
|
||||
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
|
||||
|
||||
|
||||
// Update debug info
|
||||
debugInfo.textContent = `
|
||||
Components Found: ${mountedElements.length}
|
||||
Components Found: ${totalComponents}
|
||||
Components Mounted: ${mountedCount}
|
||||
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
|
||||
Unified Vue App: ${window.__unifiedApp ? 'Initialized' : 'Not found'}
|
||||
Mounted Components: ${window.__mountedComponents ? window.__mountedComponents.length : 0}
|
||||
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
|
||||
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
|
||||
`.trim();
|
||||
|
||||
|
||||
clearInterval(checkInterval);
|
||||
|
||||
// Log to test console if available
|
||||
if (window.testLog) {
|
||||
window.testLog(`Mounted ${mountedCount} components successfully`, 'success');
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -245,37 +247,53 @@ GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let dynamicCount = 0;
|
||||
const dynamicContainer = document.getElementById('dynamicContainer');
|
||||
|
||||
|
||||
document.getElementById('addComponent').addEventListener('click', () => {
|
||||
dynamicCount++;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mount-target';
|
||||
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
|
||||
wrapper.style.marginBottom = '10px';
|
||||
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
|
||||
|
||||
// Create the custom element
|
||||
const element = document.createElement('unraid-header-os-version');
|
||||
wrapper.appendChild(element);
|
||||
dynamicContainer.appendChild(wrapper);
|
||||
|
||||
// Trigger mount if app is already loaded
|
||||
if (window.mountVueApp) {
|
||||
window.mountVueApp({
|
||||
component: window.HeaderOsVersion,
|
||||
selector: 'unraid-header-os-version',
|
||||
appId: `dynamic-${dynamicCount}`,
|
||||
});
|
||||
|
||||
// The unified mount system doesn't support dynamic addition after initial mount
|
||||
// For now, we'll just add the element and note it won't be mounted until reload
|
||||
console.log('Note: Dynamic components require page reload to mount with the unified app system');
|
||||
|
||||
// Show a message that reload is needed
|
||||
if (!wrapper.querySelector('.reload-note')) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'reload-note';
|
||||
note.style.cssText = 'color: #666; font-size: 12px; margin-top: 10px;';
|
||||
note.textContent = 'Reload page to mount this component';
|
||||
wrapper.appendChild(note);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.getElementById('removeComponent').addEventListener('click', () => {
|
||||
const lastChild = dynamicContainer.lastElementChild;
|
||||
if (lastChild) {
|
||||
// If component was mounted, unmount it properly
|
||||
const mountedElement = lastChild.querySelector('[data-vue-mounted="true"]');
|
||||
if (mountedElement && window.__mountedComponents) {
|
||||
const componentIndex = window.__mountedComponents.findIndex(c => c.element === mountedElement);
|
||||
if (componentIndex !== -1) {
|
||||
window.__mountedComponents[componentIndex].unmount();
|
||||
window.__mountedComponents.splice(componentIndex, 1);
|
||||
}
|
||||
}
|
||||
dynamicContainer.removeChild(lastChild);
|
||||
dynamicCount = Math.max(0, dynamicCount - 1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.getElementById('remountAll').addEventListener('click', () => {
|
||||
// This would require the mount function to be exposed globally
|
||||
console.log('Remounting all components...');
|
||||
// The unified app requires a full reload to remount
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
@@ -321,7 +339,18 @@ GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load the standalone app -->
|
||||
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
|
||||
<!-- Load shared header and manifest resources -->
|
||||
<script src="/test-pages/shared-header.js"></script>
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
<script src="/test-pages/test-server-state.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.initializeSharedHeader) {
|
||||
window.initializeSharedHeader('Component Mounting Test');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -185,7 +185,15 @@
|
||||
<h3>Update Modal Testing <span class="badge new">NEW</span></h3>
|
||||
<p>Test various update scenarios including expired licenses, renewals, and auth requirements</p>
|
||||
</div>
|
||||
<a href="/test-update-modal.html">Open →</a>
|
||||
<a href="/test-pages/update-modal.html">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Component Mounting Test</h3>
|
||||
<p>Test single and multiple component mounting with shared Pinia store and dynamic creation</p>
|
||||
</div>
|
||||
<a href="/test-pages/component-mounting.html">Open →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useLazyQuery } from '@vue/apollo-composable';
|
||||
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
@@ -34,11 +34,15 @@ import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
const { t } = useI18n();
|
||||
const { copyWithNotification } = useClipboardWithToast();
|
||||
|
||||
// Defer logo cleanup to avoid blocking mount
|
||||
onMounted(() => {
|
||||
const logoWrapper = document.querySelector('.logo');
|
||||
logoWrapper?.classList.remove('logo');
|
||||
nextTick(() => {
|
||||
const logoWrapper = document.querySelector('.logo');
|
||||
logoWrapper?.classList.remove('logo');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize all stores - they're needed for the UI
|
||||
const serverStore = useServerStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
const updateOsActionsStore = useUpdateOsActionsStore();
|
||||
@@ -48,10 +52,19 @@ const { osVersion, rebootType, stateDataError } = storeToRefs(serverStore);
|
||||
const { available, availableWithRenewal } = storeToRefs(updateOsStore);
|
||||
const { rebootTypeText } = storeToRefs(updateOsActionsStore);
|
||||
|
||||
// Query for version information
|
||||
const { result: versionsResult } = useQuery(INFO_VERSIONS_QUERY, null, {
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
// Use lazy query and only load when dropdown is opened
|
||||
const { load: loadVersions, result: versionsResult } = useLazyQuery(INFO_VERSIONS_QUERY);
|
||||
|
||||
// Track if we've loaded the versions yet
|
||||
const hasLoadedVersions = ref(false);
|
||||
|
||||
// Load version data only when dropdown is opened
|
||||
const handleDropdownOpen = (open: boolean) => {
|
||||
if (open && !hasLoadedVersions.value) {
|
||||
hasLoadedVersions.value = true;
|
||||
loadVersions();
|
||||
}
|
||||
};
|
||||
|
||||
// Use versions endpoint as primary source, fallback to store
|
||||
const displayOsVersion = computed(
|
||||
@@ -174,7 +187,7 @@ const updateOsStatus = computed(() => {
|
||||
</a>
|
||||
|
||||
<div class="mt-2 flex flex-wrap justify-start gap-2">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot @update:open="handleDropdownOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="link"
|
||||
|
||||
@@ -26,7 +26,7 @@ const changelogModalVisible = computed(() => updateOsStore.changelogModalVisible
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="modals" ref="modals" class="relative z-99999">
|
||||
<div id="modals" ref="modals" class="relative z-[999999]">
|
||||
<UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" />
|
||||
<UpcTrial :t="t" :open="trialModalVisible" />
|
||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||
|
||||
@@ -333,19 +333,22 @@ const showUpdateEligibility = computed(() => {
|
||||
</div>
|
||||
|
||||
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
|
||||
<div class="my-4 flex flex-col gap-y-2 text-center">
|
||||
<div class="my-4 flex flex-col gap-y-2">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<p class="text-lg">
|
||||
<p class="text-center text-lg">
|
||||
{{ t('Current Version: Unraid {0}', [osVersion]) }}
|
||||
</p>
|
||||
|
||||
<ChevronDoubleDownIcon class="mx-auto h-8 w-8 animate-pulse fill-current opacity-50" />
|
||||
|
||||
<p class="text-lg">
|
||||
<p class="text-center text-lg">
|
||||
{{ t('New Version: {0}', [callbackUpdateRelease?.name]) }}
|
||||
</p>
|
||||
|
||||
<p v-if="!callbackUpdateRelease?.version?.includes('+')" class="text-sm italic opacity-75">
|
||||
<p
|
||||
v-if="!callbackUpdateRelease?.version?.includes('+')"
|
||||
class="text-center text-sm italic opacity-75"
|
||||
>
|
||||
{{
|
||||
callbackTypeDowngrade
|
||||
? t('This downgrade will require a reboot')
|
||||
|
||||
@@ -13,14 +13,14 @@ import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
||||
cn(
|
||||
'text-header-text-secondary leading-tight font-semibold',
|
||||
'flex flex-col items-end justify-end gap-y-0.5',
|
||||
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'xs:!flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'text-xs',
|
||||
$attrs.class as ClassValue
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
|
||||
<span class="xs:inline hidden">•</span>
|
||||
<span class="xs:!inline hidden">•</span>
|
||||
<UpcServerState class="text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
import {
|
||||
autoMountAllComponents,
|
||||
autoMountComponent,
|
||||
getMountedApp,
|
||||
mountVueApp,
|
||||
} from '@/components/Wrapper/mount-engine';
|
||||
import { autoMountAllComponents } from '@/components/Wrapper/mount-engine';
|
||||
import { client as apolloClient } from '~/helpers/create-apollo-client';
|
||||
import { parse } from 'graphql';
|
||||
|
||||
@@ -22,10 +16,6 @@ function initializeGlobalDependencies() {
|
||||
// Provide Apollo client globally for all components
|
||||
provideApolloClient(apolloClient);
|
||||
|
||||
// Pre-create the teleport container to avoid mounting issues
|
||||
// This ensures the container exists before any components try to teleport to it
|
||||
ensureTeleportContainer();
|
||||
|
||||
// Initialize theme once per page load
|
||||
// This loads theme from GraphQL and applies Tailwind v4 classes
|
||||
initializeTheme().catch((error: unknown) => {
|
||||
@@ -33,9 +23,8 @@ function initializeGlobalDependencies() {
|
||||
});
|
||||
|
||||
// Expose utility functions on window for debugging/external use
|
||||
window.mountVueApp = mountVueApp;
|
||||
window.getMountedApp = getMountedApp;
|
||||
window.autoMountComponent = autoMountComponent;
|
||||
// With unified app, these are no longer needed
|
||||
// Access the unified app via window.__unifiedApp instead
|
||||
|
||||
// Expose Apollo client on window for global access
|
||||
window.apolloClient = apolloClient;
|
||||
|
||||
@@ -2,154 +2,142 @@
|
||||
// This module defines all web components and their mappings
|
||||
// Actual mounting is handled by mount-engine.ts
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
// Import CSS for bundling - this ensures Tailwind styles are included
|
||||
import '~/assets/main.css';
|
||||
// Import @unraid/ui styles which includes vue-sonner styles
|
||||
import '@unraid/ui/styles';
|
||||
|
||||
// Static imports for critical components that are always present
|
||||
// These are included in the main bundle for faster initial render
|
||||
import HeaderOsVersionCe from '@/components/HeaderOsVersion.standalone.vue';
|
||||
import ModalsCe from '@/components/Modals.standalone.vue';
|
||||
import ThemeSwitcherCe from '@/components/ThemeSwitcher.standalone.vue';
|
||||
import UnraidToaster from '@/components/UnraidToaster.vue';
|
||||
import UserProfileCe from '@/components/UserProfile.standalone.vue';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
// Type for Vue component module
|
||||
type VueComponentModule = { default: object } | object;
|
||||
import type { Component } from 'vue';
|
||||
|
||||
// Type for component mappings
|
||||
export type ComponentMapping = {
|
||||
selector: string | string[]; // Can be a single selector or array of selector aliases
|
||||
appId: string;
|
||||
} & (
|
||||
| { component: Component } // Static import
|
||||
| { loader: () => Promise<VueComponentModule> } // Dynamic import
|
||||
);
|
||||
component: Component; // The async component
|
||||
};
|
||||
|
||||
// Define component mappings
|
||||
// Critical components use static imports (already loaded)
|
||||
// Page-specific components use dynamic imports (lazy loaded)
|
||||
// Define component mappings - all components use async loading for consistency
|
||||
// Priority components (header, user profile) are listed first for faster mounting
|
||||
export const componentMappings: ComponentMapping[] = [
|
||||
{
|
||||
loader: () => import('../Auth.standalone.vue'),
|
||||
selector: 'unraid-auth',
|
||||
appId: 'auth',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ConnectSettings/ConnectSettings.standalone.vue'),
|
||||
selector: 'unraid-connect-settings',
|
||||
appId: 'connect-settings',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DownloadApiLogs.standalone.vue'),
|
||||
selector: 'unraid-download-api-logs',
|
||||
appId: 'download-api-logs',
|
||||
},
|
||||
{
|
||||
component: HeaderOsVersionCe, // Static import - always present in header
|
||||
component: defineAsyncComponent(() => import('@/components/HeaderOsVersion.standalone.vue')),
|
||||
selector: 'unraid-header-os-version',
|
||||
appId: 'header-os-version',
|
||||
},
|
||||
{
|
||||
component: ModalsCe, // Static import - global modals
|
||||
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
||||
appId: 'modals',
|
||||
},
|
||||
{
|
||||
component: UserProfileCe, // Static import - always present in header
|
||||
component: defineAsyncComponent(() => import('@/components/UserProfile.standalone.vue')),
|
||||
selector: 'unraid-user-profile',
|
||||
appId: 'user-profile',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Registration.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Auth.standalone.vue')),
|
||||
selector: 'unraid-auth',
|
||||
appId: 'auth',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../ConnectSettings/ConnectSettings.standalone.vue')),
|
||||
selector: 'unraid-connect-settings',
|
||||
appId: 'connect-settings',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')),
|
||||
selector: 'unraid-download-api-logs',
|
||||
appId: 'download-api-logs',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
|
||||
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
||||
appId: 'modals',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../Registration.standalone.vue')),
|
||||
selector: 'unraid-registration',
|
||||
appId: 'registration',
|
||||
},
|
||||
{
|
||||
loader: () => import('../WanIpCheck.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../WanIpCheck.standalone.vue')),
|
||||
selector: 'unraid-wan-ip-check',
|
||||
appId: 'wan-ip-check',
|
||||
},
|
||||
{
|
||||
loader: () => import('../CallbackHandler.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../CallbackHandler.standalone.vue')),
|
||||
selector: 'unraid-callback-handler',
|
||||
appId: 'callback-handler',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Logs/LogViewer.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Logs/LogViewer.standalone.vue')),
|
||||
selector: 'unraid-log-viewer',
|
||||
appId: 'log-viewer',
|
||||
},
|
||||
{
|
||||
loader: () => import('../SsoButton.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../SsoButton.standalone.vue')),
|
||||
selector: 'unraid-sso-button',
|
||||
appId: 'sso-button',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Activation/WelcomeModal.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Activation/WelcomeModal.standalone.vue')),
|
||||
selector: 'unraid-welcome-modal',
|
||||
appId: 'welcome-modal',
|
||||
},
|
||||
{
|
||||
loader: () => import('../UpdateOs.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../UpdateOs.standalone.vue')),
|
||||
selector: 'unraid-update-os',
|
||||
appId: 'update-os',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DowngradeOs.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../DowngradeOs.standalone.vue')),
|
||||
selector: 'unraid-downgrade-os',
|
||||
appId: 'downgrade-os',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DevSettings.vue'),
|
||||
component: defineAsyncComponent(() => import('../DevSettings.vue')),
|
||||
selector: 'unraid-dev-settings',
|
||||
appId: 'dev-settings',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ApiKeyPage.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ApiKeyPage.standalone.vue')),
|
||||
selector: ['unraid-apikey-page', 'unraid-api-key-manager'],
|
||||
appId: 'apikey-page',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ApiKeyAuthorize.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ApiKeyAuthorize.standalone.vue')),
|
||||
selector: 'unraid-apikey-authorize',
|
||||
appId: 'apikey-authorize',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DevModalTest.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../DevModalTest.standalone.vue')),
|
||||
selector: 'unraid-dev-modal-test',
|
||||
appId: 'dev-modal-test',
|
||||
},
|
||||
{
|
||||
loader: () => import('../LayoutViews/Detail/DetailTest.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../LayoutViews/Detail/DetailTest.standalone.vue')),
|
||||
selector: 'unraid-detail-test',
|
||||
appId: 'detail-test',
|
||||
},
|
||||
{
|
||||
component: ThemeSwitcherCe, // Static import - theme switcher
|
||||
component: defineAsyncComponent(() => import('@/components/ThemeSwitcher.standalone.vue')),
|
||||
selector: 'unraid-theme-switcher',
|
||||
appId: 'theme-switcher',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ColorSwitcher.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ColorSwitcher.standalone.vue')),
|
||||
selector: 'unraid-color-switcher',
|
||||
appId: 'color-switcher',
|
||||
},
|
||||
{
|
||||
component: UnraidToaster, // Static import - toaster styles need to be in main bundle
|
||||
component: defineAsyncComponent(() => import('@/components/UnraidToaster.vue')),
|
||||
selector: ['unraid-toaster', 'uui-toaster'],
|
||||
appId: 'toaster',
|
||||
},
|
||||
{
|
||||
loader: () => import('../UpdateOs/TestUpdateModal.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../UpdateOs/TestUpdateModal.standalone.vue')),
|
||||
selector: 'unraid-test-update-modal',
|
||||
appId: 'test-update-modal',
|
||||
},
|
||||
{
|
||||
loader: () => import('../TestThemeSwitcher.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../TestThemeSwitcher.standalone.vue')),
|
||||
selector: 'unraid-test-theme-switcher',
|
||||
appId: 'test-theme-switcher',
|
||||
},
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { createApp, defineAsyncComponent, h } from 'vue';
|
||||
import { createApp, createVNode, h, render } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||
import UApp from '@nuxt/ui/components/App.vue';
|
||||
import ui from '@nuxt/ui/vue-plugin';
|
||||
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
// Import component registry (only imported here to avoid ordering issues)
|
||||
import { componentMappings } from '@/components/Wrapper/component-registry';
|
||||
import { client } from '~/helpers/create-apollo-client';
|
||||
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
||||
import en_US from '~/locales/en_US.json';
|
||||
|
||||
import type { Component, App as VueApp } from 'vue';
|
||||
import type { App as VueApp } from 'vue';
|
||||
|
||||
// Import Pinia for use in Vue apps
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
@@ -19,33 +18,15 @@ import { globalPinia } from '~/store/globalPinia';
|
||||
// Ensure Apollo client is singleton
|
||||
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
|
||||
|
||||
// Global store for mounted apps
|
||||
const mountedApps = new Map<string, VueApp>();
|
||||
const mountedAppClones = new Map<string, VueApp[]>();
|
||||
const mountedAppContainers = new Map<string, HTMLElement[]>();
|
||||
|
||||
// Registry to track selector aliases - maps each selector to its canonical appId
|
||||
const selectorRegistry = new Map<string, string>(); // shadow-root containers for cleanup
|
||||
|
||||
// Extend HTMLElement to include Vue's internal properties
|
||||
interface HTMLElementWithVue extends HTMLElement {
|
||||
__vueParentComponent?: {
|
||||
appContext?: {
|
||||
app?: VueApp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Expose globally for debugging
|
||||
declare global {
|
||||
interface Window {
|
||||
mountedApps: Map<string, VueApp>;
|
||||
globalPinia: typeof globalPinia;
|
||||
__unifiedApp?: VueApp;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.mountedApps = mountedApps;
|
||||
window.globalPinia = globalPinia;
|
||||
}
|
||||
|
||||
@@ -81,16 +62,6 @@ function setupI18n() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
component: Component;
|
||||
selector: string | string[]; // Can be a single selector or array of selector aliases
|
||||
appId?: string;
|
||||
useShadowRoot?: boolean;
|
||||
props?: Record<string, unknown>;
|
||||
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
|
||||
waitForElement?: boolean; // If true, poll for element existence before mounting
|
||||
}
|
||||
|
||||
// Helper function to parse props from HTML attributes
|
||||
function parsePropsFromElement(element: Element): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
@@ -127,565 +98,103 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
|
||||
return props;
|
||||
}
|
||||
|
||||
export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
const {
|
||||
component,
|
||||
selector,
|
||||
appId,
|
||||
useShadowRoot = false,
|
||||
props = {},
|
||||
skipRecovery = false,
|
||||
waitForElement = false,
|
||||
} = options;
|
||||
|
||||
// Normalize selector to array
|
||||
const selectors = Array.isArray(selector) ? selector : [selector];
|
||||
|
||||
// Generate appId from first selector if not provided
|
||||
const canonicalAppId = appId || selectors[0];
|
||||
|
||||
// Check if any of the selectors are already registered (singleton check)
|
||||
for (const sel of selectors) {
|
||||
if (selectorRegistry.has(sel)) {
|
||||
const existingAppId = selectorRegistry.get(sel)!;
|
||||
if (mountedApps.has(existingAppId)) {
|
||||
console.debug(
|
||||
`[VueMountApp] Component already mounted as ${existingAppId} for selector ${sel}, returning existing instance`
|
||||
);
|
||||
return mountedApps.get(existingAppId)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if app is already mounted by its ID
|
||||
if (mountedApps.has(canonicalAppId)) {
|
||||
console.warn(`[VueMountApp] App ${canonicalAppId} is already mounted`);
|
||||
return mountedApps.get(canonicalAppId)!;
|
||||
}
|
||||
|
||||
// If waitForElement is true, poll for element existence
|
||||
if (waitForElement) {
|
||||
const tryMount = () => {
|
||||
// Check if any of the selectors have elements
|
||||
for (const sel of selectors) {
|
||||
const elements = document.querySelectorAll(sel);
|
||||
if (elements.length > 0) {
|
||||
try {
|
||||
// Element found, mount immediately with this selector
|
||||
mountVueApp({ ...options, selector: sel, waitForElement: false });
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Failed to mount ${appId || sel} during async mount:`, error);
|
||||
// Don't retry this component to avoid infinite loops
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No elements found, try again later
|
||||
setTimeout(tryMount, 100);
|
||||
};
|
||||
|
||||
// Start polling when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryMount);
|
||||
} else {
|
||||
tryMount();
|
||||
}
|
||||
return null; // Return null for async mounting
|
||||
}
|
||||
|
||||
// Find the first selector that has elements in the DOM
|
||||
let activeSelector: string | null = null;
|
||||
for (const sel of selectors) {
|
||||
if (document.querySelector(sel)) {
|
||||
activeSelector = sel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSelector) {
|
||||
console.warn(`[VueMountApp] No elements found for any selector: ${selectors.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Register all selectors as aliases for this app
|
||||
for (const sel of selectors) {
|
||||
selectorRegistry.set(sel, canonicalAppId);
|
||||
}
|
||||
|
||||
// Check if any elements matching the selector already have Vue apps mounted
|
||||
const potentialTargets = document.querySelectorAll(activeSelector);
|
||||
for (const target of potentialTargets) {
|
||||
const element = target as HTMLElementWithVue;
|
||||
const hasVueAttributes =
|
||||
element.hasAttribute('data-vue-mounted') ||
|
||||
element.hasAttribute('data-v-app') ||
|
||||
element.hasAttribute('data-server-rendered');
|
||||
|
||||
if (hasVueAttributes || element.__vueParentComponent) {
|
||||
// Check if the existing Vue component is actually working (has content)
|
||||
const hasContent = element.innerHTML.trim().length > 0 || element.children.length > 0;
|
||||
|
||||
if (hasContent) {
|
||||
console.info(
|
||||
`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`
|
||||
);
|
||||
// Return the existing app if we can find it
|
||||
const existingApp = mountedApps.get(canonicalAppId);
|
||||
if (existingApp) {
|
||||
return existingApp;
|
||||
}
|
||||
// If we can't find the app reference but component is working, return null (success)
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
|
||||
|
||||
try {
|
||||
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
|
||||
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
|
||||
|
||||
// Remove all Vue-related attributes
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove any Vue-injected attributes
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the element content to ensure fresh state
|
||||
element.innerHTML = '';
|
||||
|
||||
// Remove the __vueParentComponent reference without calling unmount
|
||||
delete element.__vueParentComponent;
|
||||
|
||||
console.info(
|
||||
`[VueMountApp] Cleared Vue state from ${activeSelector} without unmounting (prevents nextSibling errors)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
|
||||
// Force clear everything if normal cleanup fails
|
||||
element.innerHTML = '';
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove all data-v-* attributes
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all mount targets
|
||||
const targets = document.querySelectorAll(activeSelector);
|
||||
if (targets.length === 0) {
|
||||
console.warn(`[VueMountApp] No elements found for selector: ${activeSelector}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure teleport container exists before mounting
|
||||
ensureTeleportContainer();
|
||||
|
||||
// For the first target, parse props from HTML attributes
|
||||
const firstTarget = targets[0];
|
||||
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
|
||||
|
||||
// Create the Vue app wrapped with UApp for proper Nuxt UI functionality
|
||||
// Create and mount unified app with shared context
|
||||
export function mountUnifiedApp() {
|
||||
// Create a minimal app just for context sharing
|
||||
const app = createApp({
|
||||
name: 'StandaloneAppWrapper',
|
||||
setup() {
|
||||
// Delay component creation until setup to ensure app context is ready
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, parsedProps),
|
||||
}
|
||||
);
|
||||
},
|
||||
name: 'UnifiedContextApp',
|
||||
render: () => h('div', 'Context Provider'),
|
||||
});
|
||||
|
||||
// Setup i18n
|
||||
// Setup everything once
|
||||
const i18n = setupI18n();
|
||||
app.use(i18n);
|
||||
|
||||
// Use the shared Pinia instance - this makes it available in the app context
|
||||
app.use(globalPinia);
|
||||
|
||||
// Nuxt UI plugin
|
||||
app.use(ui);
|
||||
|
||||
// Provide Apollo client
|
||||
app.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
// UI config removed - not available
|
||||
// Mount the app to establish context
|
||||
let rootElement = document.getElementById('unraid-unified-root');
|
||||
if (!rootElement) {
|
||||
rootElement = document.createElement('div');
|
||||
rootElement.id = 'unraid-unified-root';
|
||||
rootElement.style.display = 'none';
|
||||
document.body.appendChild(rootElement);
|
||||
}
|
||||
app.mount(rootElement);
|
||||
|
||||
// Mount to all targets
|
||||
const clones: VueApp[] = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
targets.forEach((target, index) => {
|
||||
const mountTarget = target as HTMLElement;
|
||||
// Now render components to their locations using the shared context
|
||||
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
|
||||
|
||||
// Comprehensive DOM validation
|
||||
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
|
||||
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
|
||||
return;
|
||||
}
|
||||
// Components are already in priority order in component-registry
|
||||
componentMappings.forEach((mapping) => {
|
||||
const { selector, appId } = mapping;
|
||||
const selectors = Array.isArray(selector) ? selector : [selector];
|
||||
|
||||
// Special handling for PHP-generated pages that might have whitespace/comment nodes
|
||||
if (mountTarget.childNodes.length > 0) {
|
||||
let hasProblematicNodes = false;
|
||||
const nodesToRemove: Node[] = [];
|
||||
// Find first matching element
|
||||
for (const sel of selectors) {
|
||||
const element = document.querySelector(sel) as HTMLElement;
|
||||
if (element && !element.hasAttribute('data-vue-mounted')) {
|
||||
// Get the async component from mapping
|
||||
const component = mapping.component;
|
||||
|
||||
Array.from(mountTarget.childNodes).forEach((node) => {
|
||||
// Check for orphaned nodes
|
||||
if (node.parentNode !== mountTarget) {
|
||||
hasProblematicNodes = true;
|
||||
return;
|
||||
// Skip if no component is defined
|
||||
if (!component) {
|
||||
console.error(`[UnifiedMount] No component defined for ${appId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for empty text nodes or comments that could cause fragment issues
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
} else if (node.nodeType === Node.COMMENT_NODE) {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
}
|
||||
});
|
||||
// Parse props from element
|
||||
const props = parsePropsFromElement(element);
|
||||
|
||||
if (hasProblematicNodes) {
|
||||
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
|
||||
|
||||
// Remove problematic nodes
|
||||
nodesToRemove.forEach((node) => {
|
||||
try {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
} catch (_e) {
|
||||
// If removal fails, clear the entire content
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// If we still have orphaned nodes after cleanup, clear everything
|
||||
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter((node) => {
|
||||
return node.parentNode !== mountTarget;
|
||||
});
|
||||
|
||||
if (remainingInvalidChildren.length > 0) {
|
||||
console.warn(
|
||||
`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`
|
||||
);
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add unapi class for minimal styling and mark as mounted
|
||||
mountTarget.classList.add('unapi');
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
|
||||
if (useShadowRoot) {
|
||||
// Create shadow root if needed
|
||||
if (!mountTarget.shadowRoot) {
|
||||
mountTarget.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
// Create mount container in shadow root
|
||||
const container = document.createElement('div');
|
||||
container.id = 'app';
|
||||
container.setAttribute('data-app-id', canonicalAppId);
|
||||
mountTarget.shadowRoot!.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
// For the first target, use the main app, otherwise create clones
|
||||
if (index === 0) {
|
||||
try {
|
||||
app.mount(container);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp({
|
||||
name: 'StandaloneAppWrapperClone',
|
||||
// Wrap component in UApp for Nuxt UI support
|
||||
const wrappedComponent = {
|
||||
name: `${appId}-wrapper`,
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, targetProps),
|
||||
default: () => h(component, props),
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Create vnode with shared app context
|
||||
const vnode = createVNode(wrappedComponent);
|
||||
vnode.appContext = app._context; // Share the app context
|
||||
|
||||
// Clear the element and render the component into it
|
||||
element.innerHTML = '';
|
||||
render(vnode, element);
|
||||
|
||||
// Mark as mounted
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.classList.add('unapi');
|
||||
|
||||
// Store for cleanup
|
||||
mountedComponents.push({
|
||||
element,
|
||||
unmount: () => render(null, element),
|
||||
});
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia);
|
||||
clonedApp.use(ui);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
try {
|
||||
clonedApp.mount(container);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mount without shadow root
|
||||
|
||||
// For multiple targets, we need to create separate app instances
|
||||
// but they'll share the same Pinia store
|
||||
if (index === 0) {
|
||||
// First target, use the main app
|
||||
try {
|
||||
// Final validation before mounting
|
||||
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
|
||||
throw new Error(`Mount target disconnected before mounting: ${selector}`);
|
||||
}
|
||||
|
||||
app.mount(mountTarget);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
|
||||
|
||||
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
|
||||
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
|
||||
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
|
||||
|
||||
// Remove the problematic data attribute that might be causing issues
|
||||
mountTarget.removeAttribute('data-vue-mounted');
|
||||
|
||||
// Try mounting immediately
|
||||
try {
|
||||
// Ensure element is still valid
|
||||
if (mountTarget.isConnected && document.contains(mountTarget)) {
|
||||
app.mount(mountTarget);
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
console.info(
|
||||
`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`
|
||||
);
|
||||
} else {
|
||||
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
|
||||
}
|
||||
|
||||
// Return without throwing to allow other elements to mount
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't throw error - just return null to allow other components to mount
|
||||
// The error has already been logged
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Additional targets, create cloned apps with their own props
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp({
|
||||
name: 'StandaloneAppWrapperClone',
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, targetProps),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia); // Shared Pinia instance
|
||||
clonedApp.use(ui);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
try {
|
||||
clonedApp.mount(mountTarget);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store the app reference
|
||||
mountedApps.set(canonicalAppId, app);
|
||||
if (clones.length) mountedAppClones.set(canonicalAppId, clones);
|
||||
if (containers.length) mountedAppContainers.set(canonicalAppId, containers);
|
||||
// Store reference for debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__unifiedApp = app;
|
||||
window.__mountedComponents = mountedComponents;
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export function unmountVueApp(appId: string): boolean {
|
||||
const app = mountedApps.get(appId);
|
||||
if (!app) {
|
||||
console.warn(`[VueMountApp] No app found with id: ${appId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up selector registry - remove all selectors that point to this appId
|
||||
for (const [selector, registeredAppId] of selectorRegistry.entries()) {
|
||||
if (registeredAppId === appId) {
|
||||
selectorRegistry.delete(selector);
|
||||
}
|
||||
}
|
||||
|
||||
// Unmount clones first with error handling
|
||||
const clones = mountedAppClones.get(appId) ?? [];
|
||||
for (const c of clones) {
|
||||
try {
|
||||
c.unmount();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppClones.delete(appId);
|
||||
|
||||
// Remove shadow containers with error handling
|
||||
const containers = mountedAppContainers.get(appId) ?? [];
|
||||
for (const el of containers) {
|
||||
try {
|
||||
el.remove();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppContainers.delete(appId);
|
||||
|
||||
// Unmount main app with error handling
|
||||
try {
|
||||
app.unmount();
|
||||
|
||||
// Clean up data attributes from mounted elements
|
||||
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
|
||||
elements.forEach((el) => {
|
||||
if (el.classList.contains('unapi')) {
|
||||
el.removeAttribute('data-vue-mounted');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
|
||||
}
|
||||
|
||||
mountedApps.delete(appId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMountedApp(appId: string): VueApp | undefined {
|
||||
return mountedApps.get(appId);
|
||||
}
|
||||
|
||||
// Auto-mount function that waits for DOM elements to be available
|
||||
export function autoMountComponent(
|
||||
componentOrMapping: Component | { component?: Component; loader?: () => Promise<VueComponentModule> },
|
||||
selector: string | string[],
|
||||
options?: Partial<MountOptions>
|
||||
) {
|
||||
let component: Component;
|
||||
|
||||
// Handle different input types
|
||||
if ('component' in componentOrMapping && componentOrMapping.component) {
|
||||
// Direct component from mapping
|
||||
component = componentOrMapping.component;
|
||||
} else if ('loader' in componentOrMapping && componentOrMapping.loader) {
|
||||
// Async loader from mapping - create async component
|
||||
component = createAsyncComponent(componentOrMapping.loader);
|
||||
} else if (
|
||||
typeof componentOrMapping === 'object' &&
|
||||
!('component' in componentOrMapping) &&
|
||||
!('loader' in componentOrMapping)
|
||||
) {
|
||||
// Direct component passed
|
||||
component = componentOrMapping as Component;
|
||||
} else {
|
||||
console.error('[autoMountComponent] Invalid component or mapping provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to mountVueApp with waitForElement option
|
||||
mountVueApp({
|
||||
component,
|
||||
selector,
|
||||
...options,
|
||||
waitForElement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Type for Vue component module
|
||||
type VueComponentModule = { default: object } | object;
|
||||
|
||||
// Helper to create async components with consistent error handling
|
||||
export function createAsyncComponent(loader: () => Promise<VueComponentModule>) {
|
||||
return defineAsyncComponent({
|
||||
loader: async () => {
|
||||
const module = await loader();
|
||||
return 'default' in module ? module.default : module;
|
||||
},
|
||||
loadingComponent: undefined,
|
||||
errorComponent: undefined,
|
||||
delay: 0,
|
||||
timeout: 5000, // 5 second timeout
|
||||
onError(error, _retry, fail) {
|
||||
console.error('[AsyncComponent] Failed to load component:', error);
|
||||
fail();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-mount all registered components from component-registry
|
||||
// Replace the old autoMountAllComponents with the new unified approach
|
||||
export function autoMountAllComponents() {
|
||||
console.log('[AutoMountAll] Starting auto-mount for', componentMappings.length, 'components');
|
||||
|
||||
componentMappings.forEach((mapping) => {
|
||||
const { selector, appId } = mapping;
|
||||
|
||||
// Normalize selector to array for consistent handling
|
||||
const selectors = Array.isArray(selector) ? selector : [selector];
|
||||
|
||||
// Check if any of the selectors have elements in the DOM
|
||||
const hasElements = selectors.some((sel) => {
|
||||
const found = document.querySelector(sel);
|
||||
if (found) {
|
||||
console.log(`[AutoMountAll] Found element for selector: ${sel}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Only proceed if at least one selector has elements
|
||||
if (hasElements) {
|
||||
console.log(`[AutoMountAll] Mounting component: ${appId}`);
|
||||
try {
|
||||
// Pass the mapping directly to autoMountComponent
|
||||
// Let mount-engine handle component vs loader logic
|
||||
autoMountComponent(mapping, selector, {
|
||||
appId,
|
||||
useShadowRoot: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AutoMountAll] Failed to mount ${appId}:`, error);
|
||||
// Continue with next component
|
||||
}
|
||||
} else {
|
||||
console.log(`[AutoMountAll] No elements found for: ${selectors.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AutoMountAll] Auto-mount complete');
|
||||
mountUnifiedApp();
|
||||
}
|
||||
|
||||
4
web/src/helpers/globals.d.ts
vendored
4
web/src/helpers/globals.d.ts
vendored
@@ -1,5 +1,9 @@
|
||||
declare global {
|
||||
var csrf_token: string;
|
||||
interface Window {
|
||||
__unifiedApp?: unknown;
|
||||
__mountedComponents?: Array<{ element: HTMLElement; unmount: () => void }>;
|
||||
}
|
||||
}
|
||||
|
||||
// an export or import statement is required to make this file a module
|
||||
|
||||
@@ -92,6 +92,15 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
include: ['ajv', 'ajv-errors'],
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
define: {
|
||||
...sharedDefine,
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
|
||||
Reference in New Issue
Block a user