From 47c8ff1b5b37e29ded9212ac4144dba2cfdb633f Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Wed, 2 Sep 2026 10:25:16 +0900 Subject: [PATCH 01/16] ref: centralize Vitest tests under root tests Move TypeScript test suites, setup, and Tauri mocks into a source-mirroring tests tree. Update Vitest, Vite aliases, test typechecking, and package dependencies without changing Rust sources. --- package.json | 4 +- pnpm-lock.yaml | 3 ++ src/renderer/components/PluginBrowser.tsx | 8 +-- .../adapters/plugin}/hangar-adapter.test.ts | 22 ++++---- .../lib/adapters/plugin}/http-client.test.ts | 18 +++---- .../adapters/plugin}/modrinth-adapter.test.ts | 16 +++--- .../adapters/plugin}/spigot-adapter.test.ts | 22 ++++---- .../lib}/backup-commands.test.ts | 20 ++++---- .../lib}/config-commands.test.ts | 24 ++++----- .../lib}/console-history-commands.test.ts | 10 ++-- .../lib}/error-utils.test.ts | 2 +- .../lib}/file-commands.test.ts | 14 +++--- .../lib/guards}/json-guards.test.ts | 2 +- .../lib}/health-check-commands.test.ts | 6 +-- .../lib}/java-commands.test.ts | 36 ++++++------- .../lib}/ngrok-commands-store.test.ts | 14 +++--- .../lib}/ngrok-commands.test.ts | 28 +++++------ .../lib}/performance-commands.test.ts | 8 +-- .../lib}/plugin-commands-hangar.test.ts | 50 +++++++++---------- .../lib}/plugin-commands-modrinth.test.ts | 50 +++++++++---------- .../lib}/plugin-commands-spigot.test.ts | 30 +++++------ .../lib}/plugin-install-errors.test.ts | 5 +- .../lib}/proxy-commands.test.ts | 4 +- .../lib}/server-commands-crud.test.ts | 34 ++++++------- .../lib}/server-commands.test.ts | 12 ++--- .../__tests__ => tests/lib}/tauri-api.test.ts | 6 +-- .../lib}/tauri-capabilities.test.ts | 0 .../lib}/toast-entry-style.test.ts | 0 .../lib}/update-commands.test.ts | 36 ++++++------- .../lib}/version-commands.test.ts | 18 +++---- .../mocks}/tauri-api-app.ts | 0 .../mocks}/tauri-api-core.ts | 0 .../mocks}/tauri-api-event.ts | 0 .../mocks}/tauri-api-path.ts | 0 .../mocks}/tauri-api-webview-window.ts | 0 .../mocks}/tauri-api-window.ts | 0 .../mocks}/tauri-plugin-clipboard.ts | 0 .../mocks}/tauri-plugin-dialog.ts | 0 .../mocks}/tauri-plugin-fs.ts | 0 .../mocks}/tauri-plugin-global-shortcut.ts | 0 .../mocks}/tauri-plugin-http.ts | 0 .../mocks}/tauri-plugin-notification.ts | 0 .../mocks}/tauri-plugin-opener.ts | 0 .../mocks}/tauri-plugin-os.ts | 0 .../mocks}/tauri-plugin-process.ts | 0 .../mocks}/tauri-plugin-store.ts | 0 .../mocks}/tauri-plugin-updater.ts | 0 .../mocks}/tauri-plugin-window-state.ts | 0 .../components}/AppFeedbackProvider.test.tsx | 2 +- .../components}/FileEditorWorkspace.test.tsx | 4 +- .../PluginBrowser.integration.test.tsx | 14 +++--- .../components}/PluginBrowser.test.tsx | 8 +-- .../components}/SettingsWindow.test.tsx | 12 ++--- .../components}/plugin-search-query.test.tsx | 9 ++-- .../renderer/components/ui/Progress.test.tsx | 2 +- .../renderer/shared}/auto-backup.test.ts | 6 +-- .../renderer/shared}/feedback.test.ts | 6 +-- {src/test => tests}/setup.ts | 0 tsconfig.tests.json | 8 +++ vite.config.ts | 2 +- vitest.config.ts | 12 ++--- 61 files changed, 304 insertions(+), 283 deletions(-) rename {src/lib/adapters/plugin/__tests__ => tests/lib/adapters/plugin}/hangar-adapter.test.ts (81%) rename {src/lib/adapters/plugin/__tests__ => tests/lib/adapters/plugin}/http-client.test.ts (85%) rename {src/lib/adapters/plugin/__tests__ => tests/lib/adapters/plugin}/modrinth-adapter.test.ts (80%) rename {src/lib/adapters/plugin/__tests__ => tests/lib/adapters/plugin}/spigot-adapter.test.ts (80%) rename {src/lib/__tests__ => tests/lib}/backup-commands.test.ts (81%) rename {src/lib/__tests__ => tests/lib}/config-commands.test.ts (85%) rename {src/lib/__tests__ => tests/lib}/console-history-commands.test.ts (84%) rename {src/lib/__tests__ => tests/lib}/error-utils.test.ts (96%) rename {src/lib/__tests__ => tests/lib}/file-commands.test.ts (90%) rename {src/lib/guards/__tests__ => tests/lib/guards}/json-guards.test.ts (97%) rename {src/lib/__tests__ => tests/lib}/health-check-commands.test.ts (88%) rename {src/lib/__tests__ => tests/lib}/java-commands.test.ts (87%) rename {src/lib/__tests__ => tests/lib}/ngrok-commands-store.test.ts (81%) rename {src/lib/__tests__ => tests/lib}/ngrok-commands.test.ts (84%) rename {src/lib/__tests__ => tests/lib}/performance-commands.test.ts (85%) rename {src/lib/__tests__ => tests/lib}/plugin-commands-hangar.test.ts (87%) rename {src/lib/__tests__ => tests/lib}/plugin-commands-modrinth.test.ts (86%) rename {src/lib/__tests__ => tests/lib}/plugin-commands-spigot.test.ts (83%) rename {src/lib/__tests__ => tests/lib}/plugin-install-errors.test.ts (98%) rename {src/lib/__tests__ => tests/lib}/proxy-commands.test.ts (97%) rename {src/lib/__tests__ => tests/lib}/server-commands-crud.test.ts (87%) rename {src/lib/__tests__ => tests/lib}/server-commands.test.ts (87%) rename {src/lib/__tests__ => tests/lib}/tauri-api.test.ts (86%) rename {src/lib/__tests__ => tests/lib}/tauri-capabilities.test.ts (100%) rename {src/lib/__tests__ => tests/lib}/toast-entry-style.test.ts (100%) rename {src/lib/__tests__ => tests/lib}/update-commands.test.ts (84%) rename {src/lib/__tests__ => tests/lib}/version-commands.test.ts (89%) rename {src/test-mocks => tests/mocks}/tauri-api-app.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-api-core.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-api-event.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-api-path.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-api-webview-window.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-api-window.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-clipboard.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-dialog.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-fs.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-global-shortcut.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-http.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-notification.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-opener.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-os.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-process.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-store.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-updater.ts (100%) rename {src/test-mocks => tests/mocks}/tauri-plugin-window-state.ts (100%) rename {src/renderer/components/__tests__ => tests/renderer/components}/AppFeedbackProvider.test.tsx (97%) rename {src/renderer/components/__tests__ => tests/renderer/components}/FileEditorWorkspace.test.tsx (95%) rename {src/renderer/components/__tests__ => tests/renderer/components}/PluginBrowser.integration.test.tsx (92%) rename {src/renderer/components/__tests__ => tests/renderer/components}/PluginBrowser.test.tsx (89%) rename {src/renderer/components/__tests__ => tests/renderer/components}/SettingsWindow.test.tsx (85%) rename {src/renderer/components/__tests__ => tests/renderer/components}/plugin-search-query.test.tsx (93%) rename {src => tests}/renderer/components/ui/Progress.test.tsx (94%) rename {src/renderer/shared/__tests__ => tests/renderer/shared}/auto-backup.test.ts (97%) rename {src/renderer/shared/__tests__ => tests/renderer/shared}/feedback.test.ts (93%) rename {src/test => tests}/setup.ts (100%) create mode 100644 tsconfig.tests.json diff --git a/package.json b/package.json index d827b628..3e51ad5f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:plain": "vite", "build": "vite build", "preview": "vite preview", - "check": "biome check --diagnostic-level=error src", + "check": "biome check --diagnostic-level=error src tests", "check:fix": "biome check --write --diagnostic-level=error src", "lint": "biome lint --diagnostic-level=error src", "format": "biome format --write src", @@ -26,6 +26,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", + "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", "test:all": "pnpm check && pnpm test && cd src-tauri && cargo test", "test:playwright": "playwright test", "test:playwright:ui": "playwright test --ui", @@ -87,6 +88,7 @@ "@tauri-apps/cli": "^2.11.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16b96aa1..346eb17b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.2.14 version: 19.2.14 diff --git a/src/renderer/components/PluginBrowser.tsx b/src/renderer/components/PluginBrowser.tsx index b74dc938..9579908d 100644 --- a/src/renderer/components/PluginBrowser.tsx +++ b/src/renderer/components/PluginBrowser.tsx @@ -12,7 +12,6 @@ import { Loader2, type LucideIcon, Package, - RefreshCw, Search, Server, Star, @@ -1178,7 +1177,8 @@ export default function PluginBrowser({ server }: Props) { for (const entry of needsCheck) { if (entry.sourceItem) { - setUpdateStatusByItemId((prev) => ({ ...prev, [entry.sourceItem?.id]: 'checking' })); + const itemId = entry.sourceItem.id; + setUpdateStatusByItemId((prev) => ({ ...prev, [itemId]: 'checking' })); } } @@ -2524,7 +2524,9 @@ export default function PluginBrowser({ server }: Props) { searchQuery.refetch()} + onRetry={() => { + void searchQuery.refetch(); + }} retryLabel={t('plugins.browser.retry')} /> )} diff --git a/src/lib/adapters/plugin/__tests__/hangar-adapter.test.ts b/tests/lib/adapters/plugin/hangar-adapter.test.ts similarity index 81% rename from src/lib/adapters/plugin/__tests__/hangar-adapter.test.ts rename to tests/lib/adapters/plugin/hangar-adapter.test.ts index eec46ebf..29a98a7e 100644 --- a/src/lib/adapters/plugin/__tests__/hangar-adapter.test.ts +++ b/tests/lib/adapters/plugin/hangar-adapter.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const fetchJsonMock = vi.fn(); -vi.mock('../http-client', () => ({ +vi.mock('@/lib/adapters/plugin/http-client', () => ({ fetchJson: fetchJsonMock, })); @@ -24,7 +24,7 @@ describe('searchHangarProjects', () => { it('sets query/offset/limit as URL search params', async () => { fetchJsonMock.mockResolvedValueOnce({ result: [], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); await searchHangarProjects({ query: 'essentials', offset: 20, limit: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('query=essentials'); @@ -34,7 +34,7 @@ describe('searchHangarProjects', () => { it('uses hangar.papermc.io/api/v1/projects as the base URL', async () => { fetchJsonMock.mockResolvedValueOnce({ result: [], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); await searchHangarProjects({ query: '', offset: 0, limit: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('hangar.papermc.io/api/v1/projects'); @@ -42,7 +42,7 @@ describe('searchHangarProjects', () => { it('parses a valid project correctly', async () => { fetchJsonMock.mockResolvedValueOnce({ result: [VALID_PROJECT_RAW], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: 'essentials', offset: 0, limit: 10 }); expect(result).toHaveLength(1); expect(result[0].name).toBe('EssentialsX'); @@ -61,7 +61,7 @@ describe('searchHangarProjects', () => { avatarUrl: '', }; fetchJsonMock.mockResolvedValueOnce({ result: [invalidProject], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(result).toHaveLength(0); }); @@ -75,28 +75,28 @@ describe('searchHangarProjects', () => { avatarUrl: '', }; fetchJsonMock.mockResolvedValueOnce({ result: [noName], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(result).toHaveLength(0); }); it('returns empty array when result is not an array', async () => { fetchJsonMock.mockResolvedValueOnce({ result: null, pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(result).toEqual([]); }); it('returns empty array when result array is empty', async () => { fetchJsonMock.mockResolvedValueOnce({ result: [], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(result).toEqual([]); }); it('passes pagination from the response through as-is', async () => { fetchJsonMock.mockResolvedValueOnce({ result: [], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { pagination } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(pagination).toEqual(PAGINATION); }); @@ -110,7 +110,7 @@ describe('searchHangarProjects', () => { avatarUrl: '', }; fetchJsonMock.mockResolvedValueOnce({ result: [noStats], pagination: PAGINATION }); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); const { result } = await searchHangarProjects({ query: '', offset: 0, limit: 10 }); expect(result[0].stats.downloads).toBe(0); expect(result[0].stats.stars).toBe(0); @@ -118,7 +118,7 @@ describe('searchHangarProjects', () => { it('propagates errors from fetchJson', async () => { fetchJsonMock.mockRejectedValueOnce(new Error('Network error')); - const { searchHangarProjects } = await import('../hangar-adapter'); + const { searchHangarProjects } = await import('@/lib/adapters/plugin/hangar-adapter'); await expect(searchHangarProjects({ query: '', offset: 0, limit: 10 })).rejects.toThrow( 'Network error', ); diff --git a/src/lib/adapters/plugin/__tests__/http-client.test.ts b/tests/lib/adapters/plugin/http-client.test.ts similarity index 85% rename from src/lib/adapters/plugin/__tests__/http-client.test.ts rename to tests/lib/adapters/plugin/http-client.test.ts index ee6c00c6..f29f41d2 100644 --- a/src/lib/adapters/plugin/__tests__/http-client.test.ts +++ b/tests/lib/adapters/plugin/http-client.test.ts @@ -24,7 +24,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await fetchJson('https://example.com/api'); const [, init] = fetchMock.mock.calls[0]; const headers = init.headers as Headers; @@ -39,7 +39,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await fetchJson('https://example.com/api', { headers: { Accept: 'text/html' } }); const [, init] = fetchMock.mock.calls[0]; const headers = init.headers as Headers; @@ -53,7 +53,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await fetchJson('https://example.com/api', { headers: { 'User-Agent': 'custom/1.0' } }); const [, init] = fetchMock.mock.calls[0]; const headers = init.headers as Headers; @@ -68,7 +68,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue(data), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); const result = await fetchJson('https://example.com/api'); expect(result).toEqual(data); }); @@ -80,7 +80,7 @@ describe('fetchJson', () => { statusText: 'Not Found', json: vi.fn(), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await expect(fetchJson('https://example.com/api/missing')).rejects.toThrow( 'API error 404 Not Found: https://example.com/api/missing', ); @@ -93,7 +93,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await fetchJson('https://example.com/api', { method: 'POST', body: '{"a":1}' }); const [, init] = fetchMock.mock.calls[0]; expect(init.method).toBe('POST'); @@ -107,7 +107,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await expect(fetchJson('https://example.com/api')).resolves.not.toThrow(); }); @@ -118,7 +118,7 @@ describe('fetchJson', () => { statusText: 'OK', json: vi.fn().mockResolvedValue({}), }); - const { fetchJson } = await import('../http-client'); + const { fetchJson } = await import('@/lib/adapters/plugin/http-client'); await fetchJson('https://api.example.com/v2/projects'); expect(fetchMock).toHaveBeenCalledWith( 'https://api.example.com/v2/projects', @@ -134,7 +134,7 @@ describe('fetchJson', () => { init.signal?.addEventListener('abort', () => reject(new Error('aborted'))); }), ); - const { fetchJson, PLUGIN_API_TIMEOUT_MS } = await import('../http-client'); + const { fetchJson, PLUGIN_API_TIMEOUT_MS } = await import('@/lib/adapters/plugin/http-client'); const request = fetchJson('https://example.com/slow'); const assertion = expect(request).rejects.toThrow('timed out'); await vi.advanceTimersByTimeAsync(PLUGIN_API_TIMEOUT_MS); diff --git a/src/lib/adapters/plugin/__tests__/modrinth-adapter.test.ts b/tests/lib/adapters/plugin/modrinth-adapter.test.ts similarity index 80% rename from src/lib/adapters/plugin/__tests__/modrinth-adapter.test.ts rename to tests/lib/adapters/plugin/modrinth-adapter.test.ts index bab29074..c8194381 100644 --- a/src/lib/adapters/plugin/__tests__/modrinth-adapter.test.ts +++ b/tests/lib/adapters/plugin/modrinth-adapter.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const fetchJsonMock = vi.fn(); -vi.mock('../http-client', () => ({ +vi.mock('@/lib/adapters/plugin/http-client', () => ({ fetchJson: fetchJsonMock, })); @@ -14,7 +14,7 @@ describe('searchModrinthProjects', () => { it('sets query/offset/limit as URL search params', async () => { fetchJsonMock.mockResolvedValueOnce({ hits: [], total_hits: 0 }); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); await searchModrinthProjects({ query: 'worldedit', facets: '', offset: 10, limit: 20 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('query=worldedit'); @@ -24,7 +24,7 @@ describe('searchModrinthProjects', () => { it('adds facets param when facets is non-empty', async () => { fetchJsonMock.mockResolvedValueOnce({ hits: [], total_hits: 0 }); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); await searchModrinthProjects({ query: 'test', facets: '[["project_type:plugin"]]', @@ -37,7 +37,7 @@ describe('searchModrinthProjects', () => { it('omits facets param when facets is empty string', async () => { fetchJsonMock.mockResolvedValueOnce({ hits: [], total_hits: 0 }); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); await searchModrinthProjects({ query: 'test', facets: '', offset: 0, limit: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).not.toContain('facets='); @@ -59,14 +59,14 @@ describe('searchModrinthProjects', () => { total_hits: 1, }; fetchJsonMock.mockResolvedValueOnce(mockResult); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); const result = await searchModrinthProjects({ query: 'we', facets: '', offset: 0, limit: 10 }); expect(result).toEqual(mockResult); }); it('propagates errors from fetchJson', async () => { fetchJsonMock.mockRejectedValueOnce(new Error('API error 500')); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); await expect( searchModrinthProjects({ query: 'fail', facets: '', offset: 0, limit: 10 }), ).rejects.toThrow('API error 500'); @@ -74,7 +74,7 @@ describe('searchModrinthProjects', () => { it('uses https://api.modrinth.com/v2/search as the base URL', async () => { fetchJsonMock.mockResolvedValueOnce({ hits: [], total_hits: 0 }); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); await searchModrinthProjects({ query: '', facets: '', offset: 0, limit: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('api.modrinth.com/v2/search'); @@ -94,7 +94,7 @@ describe('searchModrinthProjects', () => { ], total_hits: 1, }); - const { searchModrinthProjects } = await import('../modrinth-adapter'); + const { searchModrinthProjects } = await import('@/lib/adapters/plugin/modrinth-adapter'); const result = await searchModrinthProjects({ query: 'a', facets: '', offset: 0, limit: 5 }); expect(result.hits).toHaveLength(1); expect(result.total_hits).toBe(1); diff --git a/src/lib/adapters/plugin/__tests__/spigot-adapter.test.ts b/tests/lib/adapters/plugin/spigot-adapter.test.ts similarity index 80% rename from src/lib/adapters/plugin/__tests__/spigot-adapter.test.ts rename to tests/lib/adapters/plugin/spigot-adapter.test.ts index 06561f3b..75178105 100644 --- a/src/lib/adapters/plugin/__tests__/spigot-adapter.test.ts +++ b/tests/lib/adapters/plugin/spigot-adapter.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const fetchJsonMock = vi.fn(); -vi.mock('../http-client', () => ({ +vi.mock('@/lib/adapters/plugin/http-client', () => ({ fetchJson: fetchJsonMock, })); @@ -26,7 +26,7 @@ describe('searchSpigotResources', () => { it('uses /resources/free endpoint when query is empty', async () => { fetchJsonMock.mockResolvedValueOnce([]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); await searchSpigotResources({ query: '', page: 1, size: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('/v2/resources/free'); @@ -34,7 +34,7 @@ describe('searchSpigotResources', () => { it('uses /search/resources endpoint when query is non-empty', async () => { fetchJsonMock.mockResolvedValueOnce([]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); await searchSpigotResources({ query: 'essentials', page: 1, size: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('/v2/search/resources/'); @@ -43,7 +43,7 @@ describe('searchSpigotResources', () => { it('uses /resources/free when query is only whitespace', async () => { fetchJsonMock.mockResolvedValueOnce([]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); await searchSpigotResources({ query: ' ', page: 1, size: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('/v2/resources/free'); @@ -51,7 +51,7 @@ describe('searchSpigotResources', () => { it('sets size and page as URL params with sort=-downloads', async () => { fetchJsonMock.mockResolvedValueOnce([]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); await searchSpigotResources({ query: '', page: 3, size: 20 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('size=20'); @@ -61,7 +61,7 @@ describe('searchSpigotResources', () => { it('clamps page to minimum 1 when page is 0', async () => { fetchJsonMock.mockResolvedValueOnce([]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); await searchSpigotResources({ query: '', page: 0, size: 10 }); const [url] = fetchJsonMock.mock.calls[0]; expect(url).toContain('page=1'); @@ -69,7 +69,7 @@ describe('searchSpigotResources', () => { it('parses a valid resource correctly', async () => { fetchJsonMock.mockResolvedValueOnce([VALID_RESOURCE_RAW]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); const result = await searchSpigotResources({ query: '', page: 1, size: 10 }); expect(result).toHaveLength(1); expect(result[0].id).toBe(83557); @@ -81,7 +81,7 @@ describe('searchSpigotResources', () => { it('converts relative iconUrl to absolute spigotmc.org URL', async () => { fetchJsonMock.mockResolvedValueOnce([VALID_RESOURCE_RAW]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); const result = await searchSpigotResources({ query: '', page: 1, size: 10 }); expect(result[0].iconUrl).toContain('https://www.spigotmc.org/'); }); @@ -89,14 +89,14 @@ describe('searchSpigotResources', () => { it('keeps absolute iconUrl as-is', async () => { const resource = { ...VALID_RESOURCE_RAW, icon: { url: 'https://example.com/icon.png' } }; fetchJsonMock.mockResolvedValueOnce([resource]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); const result = await searchSpigotResources({ query: '', page: 1, size: 10 }); expect(result[0].iconUrl).toBe('https://example.com/icon.png'); }); it('extracts latestVersionId from file URL version param', async () => { fetchJsonMock.mockResolvedValueOnce([VALID_RESOURCE_RAW]); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); const result = await searchSpigotResources({ query: '', page: 1, size: 10 }); expect(result[0].latestVersionId).toBe(12345); }); @@ -107,7 +107,7 @@ describe('searchSpigotResources', () => { { id: 1, name: '', tag: '', downloads: 0, premium: false, external: false }, ]; fetchJsonMock.mockResolvedValueOnce(badResources); - const { searchSpigotResources } = await import('../spigot-adapter'); + const { searchSpigotResources } = await import('@/lib/adapters/plugin/spigot-adapter'); const result = await searchSpigotResources({ query: '', page: 1, size: 10 }); expect(result).toHaveLength(0); }); diff --git a/src/lib/__tests__/backup-commands.test.ts b/tests/lib/backup-commands.test.ts similarity index 81% rename from src/lib/__tests__/backup-commands.test.ts rename to tests/lib/backup-commands.test.ts index db6bb4fe..60d96ead 100644 --- a/src/lib/__tests__/backup-commands.test.ts +++ b/tests/lib/backup-commands.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const tauriInvokeMock = vi.fn(); const tauriListenMock = vi.fn(); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: tauriListenMock, })); @@ -17,7 +17,7 @@ beforeEach(() => { describe('backup-commands', () => { it('creates a backup with server ID and relative sources only', async () => { - const { createBackup } = await import('../backup-commands'); + const { createBackup } = await import('@/lib/backup-commands'); await createBackup('server-1', 'backup-2024', ['world', 'plugins'], 9); expect(tauriInvokeMock).toHaveBeenCalledWith('create_managed_backup', { serverId: 'server-1', @@ -28,7 +28,7 @@ describe('backup-commands', () => { }); it('normalizes an empty source list to null', async () => { - const { createBackup } = await import('../backup-commands'); + const { createBackup } = await import('@/lib/backup-commands'); await createBackup('server-1', 'backup-2024', []); expect(tauriInvokeMock).toHaveBeenCalledWith('create_managed_backup', { serverId: 'server-1', @@ -43,7 +43,7 @@ describe('backup-commands', () => { { name: 'backup1.zip', isDirectory: false, size: 10, modified: 100 }, { name: 'notes.txt', isDirectory: false, size: 2, modified: 200 }, ]); - const { listBackupsWithMetadata } = await import('../backup-commands'); + const { listBackupsWithMetadata } = await import('@/lib/backup-commands'); await expect(listBackupsWithMetadata('server-1')).resolves.toEqual([ { name: 'backup1.zip', date: new Date(100_000), size: 10 }, ]); @@ -54,12 +54,12 @@ describe('backup-commands', () => { it('returns an empty list when the managed directory is unavailable', async () => { tauriInvokeMock.mockRejectedValueOnce(new Error('directory not found')); - const { listBackups } = await import('../backup-commands'); + const { listBackups } = await import('@/lib/backup-commands'); await expect(listBackups('server-1')).resolves.toEqual([]); }); it('restores a validated backup name through the managed command', async () => { - const { restoreBackup } = await import('../backup-commands'); + const { restoreBackup } = await import('@/lib/backup-commands'); await restoreBackup('server-1', 'backup-2024.zip'); expect(tauriInvokeMock).toHaveBeenCalledWith('restore_managed_backup', { serverId: 'server-1', @@ -68,8 +68,8 @@ describe('backup-commands', () => { }); it('rejects traversal in backup names before IPC', async () => { - const { restoreBackup, deleteBackup } = await import('../backup-commands'); - await expect(restoreBackup('server-1', '../outside.zip')).rejects.toThrow( + const { restoreBackup, deleteBackup } = await import('@/lib/backup-commands'); + await expect(restoreBackup('server-1', '@/lib/outside.zip')).rejects.toThrow( 'Invalid backup name', ); await expect(deleteBackup('server-1', 'nested/backup.zip')).rejects.toThrow( @@ -79,7 +79,7 @@ describe('backup-commands', () => { }); it('deletes a backup using a typed managed request', async () => { - const { deleteBackup } = await import('../backup-commands'); + const { deleteBackup } = await import('@/lib/backup-commands'); await deleteBackup('server-1', 'backup-2024.zip'); expect(tauriInvokeMock).toHaveBeenCalledWith('delete_managed_path', { request: { root: 'backups', serverId: 'server-1', relativePath: 'backup-2024.zip' }, @@ -89,7 +89,7 @@ describe('backup-commands', () => { it('registers backup progress listeners', async () => { const unlisten = vi.fn(); tauriListenMock.mockResolvedValueOnce(unlisten); - const { onBackupProgress } = await import('../backup-commands'); + const { onBackupProgress } = await import('@/lib/backup-commands'); const callback = vi.fn(); await expect(onBackupProgress(callback)).resolves.toBe(unlisten); expect(tauriListenMock).toHaveBeenCalledWith('backup-progress', callback); diff --git a/src/lib/__tests__/config-commands.test.ts b/tests/lib/config-commands.test.ts similarity index 85% rename from src/lib/__tests__/config-commands.test.ts rename to tests/lib/config-commands.test.ts index 9d9073ad..bdd6bcd2 100644 --- a/src/lib/__tests__/config-commands.test.ts +++ b/tests/lib/config-commands.test.ts @@ -33,7 +33,7 @@ describe('config-commands', () => { describe('getConfig', () => { it('returns value when key exists', async () => { getMock.mockResolvedValueOnce('dark'); - const { getConfig } = await import('../config-commands'); + const { getConfig } = await import('@/lib/config-commands'); const result = await getConfig('theme'); expect(getMock).toHaveBeenCalledWith('theme'); expect(result).toBe('dark'); @@ -41,7 +41,7 @@ describe('config-commands', () => { it('returns null when key does not exist', async () => { getMock.mockResolvedValueOnce(null); - const { getConfig } = await import('../config-commands'); + const { getConfig } = await import('@/lib/config-commands'); const result = await getConfig('missing-key'); expect(result).toBeNull(); }); @@ -49,7 +49,7 @@ describe('config-commands', () => { describe('setConfig', () => { it('sets value and saves to store', async () => { - const { setConfig } = await import('../config-commands'); + const { setConfig } = await import('@/lib/config-commands'); await setConfig('theme', 'light'); expect(setMock).toHaveBeenCalledWith('theme', 'light'); expect(saveMock).toHaveBeenCalled(); @@ -62,7 +62,7 @@ describe('config-commands', () => { ['theme', 'dark'], ['locale', 'ja'], ]); - const { getAllConfig } = await import('../config-commands'); + const { getAllConfig } = await import('@/lib/config-commands'); const result = await getAllConfig(); expect(entriesMock).toHaveBeenCalled(); expect(result).toEqual({ theme: 'dark', locale: 'ja' }); @@ -70,7 +70,7 @@ describe('config-commands', () => { it('returns empty object when store has no entries', async () => { entriesMock.mockResolvedValueOnce([]); - const { getAllConfig } = await import('../config-commands'); + const { getAllConfig } = await import('@/lib/config-commands'); const result = await getAllConfig(); expect(result).toEqual({}); }); @@ -80,7 +80,7 @@ describe('config-commands', () => { it('returns unsubscribe function from onKeyChange', async () => { const unsubscribe = vi.fn(); onKeyChangeMock.mockResolvedValueOnce(unsubscribe); - const { onConfigChange } = await import('../config-commands'); + const { onConfigChange } = await import('@/lib/config-commands'); const callback = vi.fn(); const result = await onConfigChange('theme', callback); expect(onKeyChangeMock).toHaveBeenCalledWith('theme', callback); @@ -95,7 +95,7 @@ describe('config-commands', () => { .mockResolvedValueOnce('ja') .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); - const { getAppSettings } = await import('../config-commands'); + const { getAppSettings } = await import('@/lib/config-commands'); const result = await getAppSettings(); expect(result).toEqual({ theme: 'dark', @@ -111,7 +111,7 @@ describe('config-commands', () => { .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null); - const { getAppSettings } = await import('../config-commands'); + const { getAppSettings } = await import('@/lib/config-commands'); const result = await getAppSettings(); expect(result.theme).toBeUndefined(); expect(result.locale).toBeUndefined(); @@ -125,7 +125,7 @@ describe('config-commands', () => { .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce('true'); - const { getAppSettings } = await import('../config-commands'); + const { getAppSettings } = await import('@/lib/config-commands'); const result = await getAppSettings(); expect(result.allowUnverifiedPluginDownloads).toBe(false); }); @@ -133,7 +133,7 @@ describe('config-commands', () => { describe('saveAppSettings', () => { it('saves each non-undefined setting and calls save', async () => { - const { saveAppSettings } = await import('../config-commands'); + const { saveAppSettings } = await import('@/lib/config-commands'); await saveAppSettings({ theme: 'dark', locale: 'ja', @@ -148,7 +148,7 @@ describe('config-commands', () => { }); it('skips undefined values', async () => { - const { saveAppSettings } = await import('../config-commands'); + const { saveAppSettings } = await import('@/lib/config-commands'); await saveAppSettings({ theme: 'dark', locale: undefined }); expect(setMock).toHaveBeenCalledTimes(1); expect(setMock).toHaveBeenCalledWith('theme', 'dark'); @@ -157,7 +157,7 @@ describe('config-commands', () => { describe('getServerRoot', () => { it('returns appDataDir joined with /servers', async () => { - const { getServerRoot } = await import('../config-commands'); + const { getServerRoot } = await import('@/lib/config-commands'); const result = await getServerRoot(); expect(result).toBe('/home/user/.local/share/mc-vector/servers'); }); diff --git a/src/lib/__tests__/console-history-commands.test.ts b/tests/lib/console-history-commands.test.ts similarity index 84% rename from src/lib/__tests__/console-history-commands.test.ts rename to tests/lib/console-history-commands.test.ts index 4006225d..561c5062 100644 --- a/src/lib/__tests__/console-history-commands.test.ts +++ b/tests/lib/console-history-commands.test.ts @@ -28,7 +28,7 @@ describe('console-history-commands', () => { it('returns stored history for the given serverId', async () => { const history = ['op list', 'say hello']; getMock.mockResolvedValueOnce(history); - const { loadCommandHistory } = await import('../console-history-commands'); + const { loadCommandHistory } = await import('@/lib/console-history-commands'); const result = await loadCommandHistory('server-1'); expect(getMock).toHaveBeenCalledWith('console-history:server-1'); expect(result).toEqual(history); @@ -36,7 +36,7 @@ describe('console-history-commands', () => { it('returns empty array when no history is stored', async () => { getMock.mockResolvedValueOnce(null); - const { loadCommandHistory } = await import('../console-history-commands'); + const { loadCommandHistory } = await import('@/lib/console-history-commands'); const result = await loadCommandHistory('server-1'); expect(result).toEqual([]); }); @@ -45,7 +45,7 @@ describe('console-history-commands', () => { describe('saveCommandHistory', () => { it('saves history with the correct prefixed key', async () => { const history = ['op list', 'say hello']; - const { saveCommandHistory } = await import('../console-history-commands'); + const { saveCommandHistory } = await import('@/lib/console-history-commands'); await saveCommandHistory('server-1', history); expect(setMock).toHaveBeenCalledWith('console-history:server-1', history); expect(saveMock).toHaveBeenCalled(); @@ -53,7 +53,7 @@ describe('console-history-commands', () => { it('trims history to the last 200 entries when over limit', async () => { const history = Array.from({ length: 250 }, (_, i) => `command-${i}`); - const { saveCommandHistory } = await import('../console-history-commands'); + const { saveCommandHistory } = await import('@/lib/console-history-commands'); await saveCommandHistory('server-1', history); const saved = setMock.mock.calls[0][1] as string[]; expect(saved).toHaveLength(200); @@ -63,7 +63,7 @@ describe('console-history-commands', () => { it('does not trim history that is within the 200-entry limit', async () => { const history = Array.from({ length: 100 }, (_, i) => `cmd-${i}`); - const { saveCommandHistory } = await import('../console-history-commands'); + const { saveCommandHistory } = await import('@/lib/console-history-commands'); await saveCommandHistory('server-2', history); const saved = setMock.mock.calls[0][1] as string[]; expect(saved).toHaveLength(100); diff --git a/src/lib/__tests__/error-utils.test.ts b/tests/lib/error-utils.test.ts similarity index 96% rename from src/lib/__tests__/error-utils.test.ts rename to tests/lib/error-utils.test.ts index fb5859b2..d32fd904 100644 --- a/src/lib/__tests__/error-utils.test.ts +++ b/tests/lib/error-utils.test.ts @@ -1,5 +1,5 @@ +import { logError, toErrorMessage } from '@/lib/error-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { logError, toErrorMessage } from '../error-utils'; describe('toErrorMessage', () => { it('returns error.message for an Error instance', () => { diff --git a/src/lib/__tests__/file-commands.test.ts b/tests/lib/file-commands.test.ts similarity index 90% rename from src/lib/__tests__/file-commands.test.ts rename to tests/lib/file-commands.test.ts index fc31a937..e1860180 100644 --- a/src/lib/__tests__/file-commands.test.ts +++ b/tests/lib/file-commands.test.ts @@ -4,7 +4,7 @@ const tauriInvokeMock = vi.fn(); const appDataDirMock = vi.fn(); vi.mock('@tauri-apps/api/path', () => ({ appDataDir: appDataDirMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: vi.fn() })); const serverRoot = '/app-data/servers/s1'; @@ -31,7 +31,7 @@ describe('managed file commands', () => { } return Promise.resolve(undefined); }); - const { listFiles } = await import('../file-commands'); + const { listFiles } = await import('@/lib/file-commands'); await expect(listFiles(serverRoot)).resolves.toEqual([ { name: 'server.jar', isDirectory: false, size: 10, modified: 1 }, ]); @@ -41,20 +41,20 @@ describe('managed file commands', () => { }); it('rejects paths outside app-managed storage before IPC', async () => { - const { listFiles } = await import('../file-commands'); + const { listFiles } = await import('@/lib/file-commands'); await expect(listFiles('/tmp/outside')).rejects.toThrow('outside MC-Vector managed storage'); expect(tauriInvokeMock).not.toHaveBeenCalled(); }); it('rejects traversal and missing paths', async () => { - const { listFiles, createFile } = await import('../file-commands'); + const { listFiles, createFile } = await import('@/lib/file-commands'); await expect(listFiles(`${serverRoot}/../outside`)).rejects.toThrow('Path traversal'); await expect(createFile(serverRoot, '')).rejects.toThrow('Invalid file or folder name'); }); it('writes, creates, deletes, and moves using managed requests', async () => { const { saveFileContent, createFile, createFolder, deleteItem, moveItem } = await import( - '../file-commands' + '@/lib/file-commands' ); await saveFileContent(`${serverRoot}/server.properties`, 'level-name=world'); await createFile(serverRoot, 'new.txt'); @@ -88,7 +88,7 @@ describe('managed file commands', () => { } return Promise.resolve(undefined); }); - const { importFile, importFilesFromPaths } = await import('../file-commands'); + const { importFile, importFilesFromPaths } = await import('@/lib/file-commands'); await expect(importFile(`${serverRoot}/plugins`)).resolves.toBe( `${serverRoot}/plugins/example.jar`, ); @@ -102,7 +102,7 @@ describe('managed file commands', () => { }); it('serializes JSON through the managed text command', async () => { - const { readJsonFile, writeJsonFile } = await import('../file-commands'); + const { readJsonFile, writeJsonFile } = await import('@/lib/file-commands'); tauriInvokeMock.mockImplementation((command: string) => { if (command === 'resolve_managed_path') return Promise.resolve('/resolved'); if (command === 'read_managed_text_file') return Promise.resolve('{"key":"value"}'); diff --git a/src/lib/guards/__tests__/json-guards.test.ts b/tests/lib/guards/json-guards.test.ts similarity index 97% rename from src/lib/guards/__tests__/json-guards.test.ts rename to tests/lib/guards/json-guards.test.ts index 487b314f..0019b17c 100644 --- a/src/lib/guards/__tests__/json-guards.test.ts +++ b/tests/lib/guards/json-guards.test.ts @@ -1,5 +1,5 @@ +import { asNumber, asString, isRecord } from '@/lib/guards/json-guards'; import { describe, expect, it } from 'vitest'; -import { asNumber, asString, isRecord } from '../json-guards'; describe('isRecord', () => { it('returns true for a plain object', () => { diff --git a/src/lib/__tests__/health-check-commands.test.ts b/tests/lib/health-check-commands.test.ts similarity index 88% rename from src/lib/__tests__/health-check-commands.test.ts rename to tests/lib/health-check-commands.test.ts index d6dbf6a7..b749481d 100644 --- a/src/lib/__tests__/health-check-commands.test.ts +++ b/tests/lib/health-check-commands.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const tauriInvokeMock = vi.fn(); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn(), })); @@ -24,7 +24,7 @@ describe('health-check-commands', () => { motd: 'A Minecraft Server', }; tauriInvokeMock.mockResolvedValueOnce(pingResult); - const { pingServer } = await import('../health-check-commands'); + const { pingServer } = await import('@/lib/health-check-commands'); const result = await pingServer('mc.example.com', 25565); expect(tauriInvokeMock).toHaveBeenCalledWith('ping_server', { host: 'mc.example.com', @@ -43,7 +43,7 @@ describe('health-check-commands', () => { motd: null, }; tauriInvokeMock.mockResolvedValueOnce(offlineResult); - const { pingServer } = await import('../health-check-commands'); + const { pingServer } = await import('@/lib/health-check-commands'); const result = await pingServer('localhost', 25565); expect(result.online).toBe(false); expect(result.players_online).toBeNull(); diff --git a/src/lib/__tests__/java-commands.test.ts b/tests/lib/java-commands.test.ts similarity index 87% rename from src/lib/__tests__/java-commands.test.ts rename to tests/lib/java-commands.test.ts index 8596fcd5..ac65c089 100644 --- a/src/lib/__tests__/java-commands.test.ts +++ b/tests/lib/java-commands.test.ts @@ -14,8 +14,8 @@ const logErrorMock = vi.fn(); vi.mock('@tauri-apps/api/path', () => ({ appDataDir: appDataDirMock })); vi.mock('@tauri-apps/plugin-dialog', () => ({ open: openMock })); vi.mock('@tauri-apps/plugin-store', () => ({ load: loadMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: tauriListenMock })); -vi.mock('../error-utils', () => ({ logError: logErrorMock })); +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: tauriListenMock })); +vi.mock('@/lib/error-utils', () => ({ logError: logErrorMock })); beforeEach(() => { vi.resetModules(); @@ -36,7 +36,7 @@ describe('getJavaVersions', () => { const versions = [{ version: 21, path: '/java/jdk-21', name: 'Java 21' }]; storeGet.mockResolvedValue(versions); - const { getJavaVersions } = await import('../java-commands'); + const { getJavaVersions } = await import('@/lib/java-commands'); const result = await getJavaVersions(); expect(result).toEqual(versions); expect(storeGet).toHaveBeenCalledWith('javaVersions'); @@ -45,7 +45,7 @@ describe('getJavaVersions', () => { it('returns empty array when store has no entry', async () => { storeGet.mockResolvedValue(undefined); - const { getJavaVersions } = await import('../java-commands'); + const { getJavaVersions } = await import('@/lib/java-commands'); const result = await getJavaVersions(); expect(result).toEqual([]); }); @@ -57,7 +57,7 @@ describe('saveJavaVersions', () => { storeSave.mockResolvedValue(undefined); const versions = [{ version: 17, path: '/java/jdk-17', name: 'Java 17' }]; - const { saveJavaVersions } = await import('../java-commands'); + const { saveJavaVersions } = await import('@/lib/java-commands'); await saveJavaVersions(versions); expect(storeSet).toHaveBeenCalledWith('javaVersions', versions); @@ -73,7 +73,7 @@ describe('downloadJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { downloadJava } = await import('../java-commands'); + const { downloadJava } = await import('@/lib/java-commands'); const result = await downloadJava(21); expect(result).toBe(true); @@ -91,7 +91,7 @@ describe('downloadJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { downloadJava } = await import('../java-commands'); + const { downloadJava } = await import('@/lib/java-commands'); await downloadJava(17); expect(tauriInvokeMock).toHaveBeenCalledWith('download_java', { majorVersion: 17 }); @@ -104,7 +104,7 @@ describe('downloadJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { downloadJava } = await import('../java-commands'); + const { downloadJava } = await import('@/lib/java-commands'); await downloadJava(21); expect(tauriInvokeMock).toHaveBeenCalledWith('download_java', { majorVersion: 21 }); @@ -114,7 +114,7 @@ describe('downloadJava', () => { appDataDirMock.mockResolvedValue('/app-data'); tauriInvokeMock.mockRejectedValue(new Error('network error')); - const { downloadJava } = await import('../java-commands'); + const { downloadJava } = await import('@/lib/java-commands'); const result = await downloadJava(21); expect(result).toBe(false); @@ -132,7 +132,7 @@ describe('downloadJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { downloadJava } = await import('../java-commands'); + const { downloadJava } = await import('@/lib/java-commands'); await downloadJava(21); const savedVersions = storeSet.mock.calls[0][1] as unknown[]; @@ -145,7 +145,7 @@ describe('deleteJava', () => { it('does nothing when version is not found in store', async () => { storeGet.mockResolvedValue([{ version: 17, path: '/java/jdk-17', name: 'Java 17' }]); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await deleteJava(21); expect(tauriInvokeMock).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe('deleteJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await deleteJava(21); expect(tauriInvokeMock).not.toHaveBeenCalled(); @@ -173,7 +173,7 @@ describe('deleteJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await deleteJava(21); expect(tauriInvokeMock).toHaveBeenCalledWith('delete_managed_path', { @@ -189,7 +189,7 @@ describe('deleteJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await expect(deleteJava(21)).rejects.toThrow('No such file or directory'); expect(storeSet).not.toHaveBeenCalled(); }); @@ -199,7 +199,7 @@ describe('deleteJava', () => { appDataDirMock.mockResolvedValue('/app-data'); tauriInvokeMock.mockRejectedValue(new Error('permission denied')); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await expect(deleteJava(21)).rejects.toThrow('permission denied'); }); @@ -216,7 +216,7 @@ describe('deleteJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await deleteJava(21); expect(tauriInvokeMock).toHaveBeenCalledWith('delete_managed_path', { @@ -231,7 +231,7 @@ describe('deleteJava', () => { storeSet.mockResolvedValue(undefined); storeSave.mockResolvedValue(undefined); - const { deleteJava } = await import('../java-commands'); + const { deleteJava } = await import('@/lib/java-commands'); await deleteJava(21); // Stored paths cannot redirect the deletion target. @@ -247,7 +247,7 @@ describe('onJavaDownloadProgress', () => { tauriListenMock.mockResolvedValue(unlistenFn); const callback = vi.fn(); - const { onJavaDownloadProgress } = await import('../java-commands'); + const { onJavaDownloadProgress } = await import('@/lib/java-commands'); const result = await onJavaDownloadProgress(callback); expect(tauriListenMock).toHaveBeenCalledWith('java-download-progress', callback); diff --git a/src/lib/__tests__/ngrok-commands-store.test.ts b/tests/lib/ngrok-commands-store.test.ts similarity index 81% rename from src/lib/__tests__/ngrok-commands-store.test.ts rename to tests/lib/ngrok-commands-store.test.ts index 5ed46542..bf68f671 100644 --- a/src/lib/__tests__/ngrok-commands-store.test.ts +++ b/tests/lib/ngrok-commands-store.test.ts @@ -14,7 +14,7 @@ vi.mock('@tauri-apps/plugin-store', () => ({ }), })); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: vi.fn(), tauriListen: vi.fn(), })); @@ -31,7 +31,7 @@ describe('ngrok-commands (store)', () => { describe('getNgrokToken', () => { it('returns token string when stored', async () => { getMock.mockResolvedValueOnce('ngrok-token-abc'); - const { getNgrokToken } = await import('../ngrok-commands'); + const { getNgrokToken } = await import('@/lib/ngrok-commands'); const result = await getNgrokToken(); expect(getMock).toHaveBeenCalledWith('ngrokToken'); expect(result).toBe('ngrok-token-abc'); @@ -39,7 +39,7 @@ describe('ngrok-commands (store)', () => { it('returns null when no token is stored', async () => { getMock.mockResolvedValueOnce(null); - const { getNgrokToken } = await import('../ngrok-commands'); + const { getNgrokToken } = await import('@/lib/ngrok-commands'); const result = await getNgrokToken(); expect(result).toBeNull(); }); @@ -47,7 +47,7 @@ describe('ngrok-commands (store)', () => { describe('setNgrokToken', () => { it('sets token and saves to store', async () => { - const { setNgrokToken } = await import('../ngrok-commands'); + const { setNgrokToken } = await import('@/lib/ngrok-commands'); await setNgrokToken('my-secret-token'); expect(setMock).toHaveBeenCalledWith('ngrokToken', 'my-secret-token'); expect(saveMock).toHaveBeenCalled(); @@ -56,7 +56,7 @@ describe('ngrok-commands (store)', () => { describe('clearNgrokToken', () => { it('deletes ngrokToken key and saves', async () => { - const { clearNgrokToken } = await import('../ngrok-commands'); + const { clearNgrokToken } = await import('@/lib/ngrok-commands'); await clearNgrokToken(); expect(deleteMock).toHaveBeenCalledWith('ngrokToken'); expect(saveMock).toHaveBeenCalled(); @@ -66,14 +66,14 @@ describe('ngrok-commands (store)', () => { describe('hasNgrokToken', () => { it('returns true when token exists', async () => { getMock.mockResolvedValueOnce('some-token'); - const { hasNgrokToken } = await import('../ngrok-commands'); + const { hasNgrokToken } = await import('@/lib/ngrok-commands'); const result = await hasNgrokToken(); expect(result).toBe(true); }); it('returns false when no token exists', async () => { getMock.mockResolvedValueOnce(null); - const { hasNgrokToken } = await import('../ngrok-commands'); + const { hasNgrokToken } = await import('@/lib/ngrok-commands'); const result = await hasNgrokToken(); expect(result).toBe(false); }); diff --git a/src/lib/__tests__/ngrok-commands.test.ts b/tests/lib/ngrok-commands.test.ts similarity index 84% rename from src/lib/__tests__/ngrok-commands.test.ts rename to tests/lib/ngrok-commands.test.ts index 1d49575e..29eacbd4 100644 --- a/src/lib/__tests__/ngrok-commands.test.ts +++ b/tests/lib/ngrok-commands.test.ts @@ -7,7 +7,7 @@ const setMock = vi.fn(); const deleteMock = vi.fn(); const saveMock = vi.fn(); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: tauriListenMock, })); @@ -35,7 +35,7 @@ describe('ngrok-commands', () => { describe('startNgrok', () => { it('invokes start_ngrok with correct args', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { startNgrok } = await import('../ngrok-commands'); + const { startNgrok } = await import('@/lib/ngrok-commands'); await startNgrok('tcp', 25565, 'token123', 'server-1'); expect(tauriInvokeMock).toHaveBeenCalledWith('start_ngrok', { protocol: 'tcp', @@ -49,7 +49,7 @@ describe('ngrok-commands', () => { describe('stopNgrok', () => { it('invokes stop_ngrok with empty args', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { stopNgrok } = await import('../ngrok-commands'); + const { stopNgrok } = await import('@/lib/ngrok-commands'); await stopNgrok(); expect(tauriInvokeMock).toHaveBeenCalledWith('stop_ngrok', {}); }); @@ -58,7 +58,7 @@ describe('ngrok-commands', () => { describe('downloadNgrok', () => { it('invokes download_ngrok and returns installed binary path', async () => { tauriInvokeMock.mockResolvedValueOnce('/usr/local/bin/ngrok'); - const { downloadNgrok } = await import('../ngrok-commands'); + const { downloadNgrok } = await import('@/lib/ngrok-commands'); const result = await downloadNgrok(); expect(tauriInvokeMock).toHaveBeenCalledWith('download_ngrok'); expect(result).toBe('/usr/local/bin/ngrok'); @@ -68,7 +68,7 @@ describe('ngrok-commands', () => { describe('isNgrokInstalled', () => { it('returns true when ngrok binary exists', async () => { tauriInvokeMock.mockResolvedValueOnce(true); - const { isNgrokInstalled } = await import('../ngrok-commands'); + const { isNgrokInstalled } = await import('@/lib/ngrok-commands'); const result = await isNgrokInstalled(); expect(tauriInvokeMock).toHaveBeenCalledWith('is_ngrok_installed'); expect(result).toBe(true); @@ -76,7 +76,7 @@ describe('ngrok-commands', () => { it('returns false when ngrok binary is missing', async () => { tauriInvokeMock.mockResolvedValueOnce(false); - const { isNgrokInstalled } = await import('../ngrok-commands'); + const { isNgrokInstalled } = await import('@/lib/ngrok-commands'); const result = await isNgrokInstalled(); expect(result).toBe(false); }); @@ -85,7 +85,7 @@ describe('ngrok-commands', () => { describe('getNgrokToken', () => { it('returns stored token string', async () => { getMock.mockResolvedValueOnce('my-auth-token'); - const { getNgrokToken } = await import('../ngrok-commands'); + const { getNgrokToken } = await import('@/lib/ngrok-commands'); const result = await getNgrokToken(); expect(getMock).toHaveBeenCalledWith('ngrokToken'); expect(result).toBe('my-auth-token'); @@ -93,7 +93,7 @@ describe('ngrok-commands', () => { it('returns null when no token is stored', async () => { getMock.mockResolvedValueOnce(undefined); - const { getNgrokToken } = await import('../ngrok-commands'); + const { getNgrokToken } = await import('@/lib/ngrok-commands'); const result = await getNgrokToken(); expect(result).toBeNull(); }); @@ -103,7 +103,7 @@ describe('ngrok-commands', () => { it('sets token and saves store', async () => { setMock.mockResolvedValueOnce(undefined); saveMock.mockResolvedValueOnce(undefined); - const { setNgrokToken } = await import('../ngrok-commands'); + const { setNgrokToken } = await import('@/lib/ngrok-commands'); await setNgrokToken('new-token'); expect(setMock).toHaveBeenCalledWith('ngrokToken', 'new-token'); expect(saveMock).toHaveBeenCalled(); @@ -114,7 +114,7 @@ describe('ngrok-commands', () => { it('deletes token key and saves store', async () => { deleteMock.mockResolvedValueOnce(undefined); saveMock.mockResolvedValueOnce(undefined); - const { clearNgrokToken } = await import('../ngrok-commands'); + const { clearNgrokToken } = await import('@/lib/ngrok-commands'); await clearNgrokToken(); expect(deleteMock).toHaveBeenCalledWith('ngrokToken'); expect(saveMock).toHaveBeenCalled(); @@ -124,14 +124,14 @@ describe('ngrok-commands', () => { describe('hasNgrokToken', () => { it('returns true when token exists in store', async () => { getMock.mockResolvedValueOnce('some-token'); - const { hasNgrokToken } = await import('../ngrok-commands'); + const { hasNgrokToken } = await import('@/lib/ngrok-commands'); const result = await hasNgrokToken(); expect(result).toBe(true); }); it('returns false when no token in store', async () => { getMock.mockResolvedValueOnce(undefined); - const { hasNgrokToken } = await import('../ngrok-commands'); + const { hasNgrokToken } = await import('@/lib/ngrok-commands'); const result = await hasNgrokToken(); expect(result).toBe(false); }); @@ -141,7 +141,7 @@ describe('ngrok-commands', () => { it('registers listener for ngrok-log event and returns unlisten fn', async () => { const unlistenFn = vi.fn(); tauriListenMock.mockResolvedValueOnce(unlistenFn); - const { onNgrokLog } = await import('../ngrok-commands'); + const { onNgrokLog } = await import('@/lib/ngrok-commands'); const callback = vi.fn(); const unlisten = await onNgrokLog(callback); expect(tauriListenMock).toHaveBeenCalledWith('ngrok-log', callback); @@ -153,7 +153,7 @@ describe('ngrok-commands', () => { it('registers listener for ngrok-status-change event and returns unlisten fn', async () => { const unlistenFn = vi.fn(); tauriListenMock.mockResolvedValueOnce(unlistenFn); - const { onNgrokStatusChange } = await import('../ngrok-commands'); + const { onNgrokStatusChange } = await import('@/lib/ngrok-commands'); const callback = vi.fn(); const unlisten = await onNgrokStatusChange(callback); expect(tauriListenMock).toHaveBeenCalledWith('ngrok-status-change', callback); diff --git a/src/lib/__tests__/performance-commands.test.ts b/tests/lib/performance-commands.test.ts similarity index 85% rename from src/lib/__tests__/performance-commands.test.ts rename to tests/lib/performance-commands.test.ts index 88aba35b..6361ea94 100644 --- a/src/lib/__tests__/performance-commands.test.ts +++ b/tests/lib/performance-commands.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const tauriInvokeMock = vi.fn(); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn(), })); @@ -20,7 +20,7 @@ describe('performance-commands', () => { [{ text: 'WARN', color: '#ffff00', backgroundColor: null, fontWeight: 700 }], ]; tauriInvokeMock.mockResolvedValueOnce(parsed); - const { parseAnsiLines } = await import('../performance-commands'); + const { parseAnsiLines } = await import('@/lib/performance-commands'); const result = await parseAnsiLines(['INFO', 'WARN']); expect(tauriInvokeMock).toHaveBeenCalledWith('parse_ansi_lines', { lines: ['INFO', 'WARN'], @@ -30,7 +30,7 @@ describe('performance-commands', () => { it('returns empty array for empty input', async () => { tauriInvokeMock.mockResolvedValueOnce([]); - const { parseAnsiLines } = await import('../performance-commands'); + const { parseAnsiLines } = await import('@/lib/performance-commands'); const result = await parseAnsiLines([]); expect(tauriInvokeMock).toHaveBeenCalledWith('parse_ansi_lines', { lines: [] }); expect(result).toEqual([]); @@ -41,7 +41,7 @@ describe('performance-commands', () => { [{ text: 'plain text', color: null, backgroundColor: null, fontWeight: null }], ]; tauriInvokeMock.mockResolvedValueOnce(parsed); - const { parseAnsiLines } = await import('../performance-commands'); + const { parseAnsiLines } = await import('@/lib/performance-commands'); const result = await parseAnsiLines(['plain text']); expect(result[0][0].color).toBeNull(); expect(result[0][0].text).toBe('plain text'); diff --git a/src/lib/__tests__/plugin-commands-hangar.test.ts b/tests/lib/plugin-commands-hangar.test.ts similarity index 87% rename from src/lib/__tests__/plugin-commands-hangar.test.ts rename to tests/lib/plugin-commands-hangar.test.ts index 9487717d..d151cf0b 100644 --- a/src/lib/__tests__/plugin-commands-hangar.test.ts +++ b/tests/lib/plugin-commands-hangar.test.ts @@ -5,8 +5,8 @@ const tauriInvokeMock = vi.fn(); const searchHangarProjectsMock = vi.fn(); vi.mock('@tauri-apps/plugin-http', () => ({ fetch: fetchMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); -vi.mock('../adapters/plugin/hangar-adapter', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); +vi.mock('@/lib/adapters/plugin/hangar-adapter', () => ({ searchHangarProjects: searchHangarProjectsMock, })); @@ -62,7 +62,7 @@ describe('plugin-commands (Hangar)', () => { result: [mockProject], pagination: { offset: 0, limit: 25, count: 1 }, }); - const { searchHangar } = await import('../plugin-commands'); + const { searchHangar } = await import('@/lib/plugin-commands'); const result = await searchHangar('essentials', 0); expect(searchHangarProjectsMock).toHaveBeenCalledWith({ query: 'essentials', @@ -75,7 +75,7 @@ describe('plugin-commands (Hangar)', () => { it('propagates errors from the adapter', async () => { searchHangarProjectsMock.mockRejectedValueOnce(new Error('Hangar error')); - const { searchHangar } = await import('../plugin-commands'); + const { searchHangar } = await import('@/lib/plugin-commands'); await expect(searchHangar('fail', 0)).rejects.toThrow('Hangar error'); }); }); @@ -86,7 +86,7 @@ describe('plugin-commands (Hangar)', () => { '1.21', ]); fetchMock.mockResolvedValueOnce(makeFetchResponse({ result: [versionRaw], pagination: {} })); - const { getHangarVersions } = await import('../plugin-commands'); + const { getHangarVersions } = await import('@/lib/plugin-commands'); await getHangarVersions('owner', 'slug', 'PAPER'); const [url] = fetchMock.mock.calls[0]; expect(url).toContain('platform=PAPER'); @@ -94,7 +94,7 @@ describe('plugin-commands (Hangar)', () => { it('omits platform param when no platform provided', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })); - const { getHangarVersions } = await import('../plugin-commands'); + const { getHangarVersions } = await import('@/lib/plugin-commands'); await getHangarVersions('owner', 'slug'); const [url] = fetchMock.mock.calls[0]; expect(url).not.toContain('platform='); @@ -103,7 +103,7 @@ describe('plugin-commands (Hangar)', () => { it('URL-encodes owner and slug', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })); - const { getHangarVersions } = await import('../plugin-commands'); + const { getHangarVersions } = await import('@/lib/plugin-commands'); await getHangarVersions('My Owner', 'my slug'); const [url] = fetchMock.mock.calls[0]; expect(url).toContain('My%20Owner'); @@ -123,7 +123,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { getHangarVersions } = await import('../plugin-commands'); + const { getHangarVersions } = await import('@/lib/plugin-commands'); const result = await getHangarVersions('owner', 'slug', 'PAPER'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(result).toHaveLength(1); @@ -131,7 +131,7 @@ describe('plugin-commands (Hangar)', () => { it('does not retry when platform is not provided', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })); - const { getHangarVersions } = await import('../plugin-commands'); + const { getHangarVersions } = await import('@/lib/plugin-commands'); await getHangarVersions('owner', 'slug'); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -150,7 +150,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -169,7 +169,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -192,7 +192,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -211,7 +211,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -226,7 +226,7 @@ describe('plugin-commands (Hangar)', () => { fetchMock .mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })) .mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -247,7 +247,7 @@ describe('plugin-commands (Hangar)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ result: [versionWithoutFileName], pagination: {} }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 'my-plugin', @@ -266,7 +266,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { resolveHangarDownload } = await import('../plugin-commands'); + const { resolveHangarDownload } = await import('@/lib/plugin-commands'); const result = await resolveHangarDownload({ owner: 'o', slug: 's', @@ -287,7 +287,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { checkHangarCompatibility } = await import('../plugin-commands'); + const { checkHangarCompatibility } = await import('@/lib/plugin-commands'); const result = await checkHangarCompatibility({ owner: 'o', slug: 's', @@ -307,7 +307,7 @@ describe('plugin-commands (Hangar)', () => { pagination: {}, }), ); - const { checkHangarCompatibility } = await import('../plugin-commands'); + const { checkHangarCompatibility } = await import('@/lib/plugin-commands'); const result = await checkHangarCompatibility({ owner: 'o', slug: 's', @@ -322,7 +322,7 @@ describe('plugin-commands (Hangar)', () => { fetchMock .mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })) .mockResolvedValueOnce(makeFetchResponse({ result: [], pagination: {} })); - const { checkHangarCompatibility } = await import('../plugin-commands'); + const { checkHangarCompatibility } = await import('@/lib/plugin-commands'); const result = await checkHangarCompatibility({ owner: 'o', slug: 's', @@ -339,7 +339,7 @@ describe('plugin-commands (Hangar)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ description: 'Short desc', mainPageContent: 'Full body text' }), ); - const { getHangarProjectBody } = await import('../plugin-commands'); + const { getHangarProjectBody } = await import('@/lib/plugin-commands'); const result = await getHangarProjectBody('owner', 'slug'); expect(result).toBe('Full body text'); }); @@ -348,14 +348,14 @@ describe('plugin-commands (Hangar)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ description: 'Short desc', mainPageContent: null }), ); - const { getHangarProjectBody } = await import('../plugin-commands'); + const { getHangarProjectBody } = await import('@/lib/plugin-commands'); const result = await getHangarProjectBody('owner', 'slug'); expect(result).toBe('Short desc'); }); it('returns null when both mainPageContent and description are empty', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ description: '', mainPageContent: '' })); - const { getHangarProjectBody } = await import('../plugin-commands'); + const { getHangarProjectBody } = await import('@/lib/plugin-commands'); const result = await getHangarProjectBody('owner', 'slug'); expect(result).toBeNull(); }); @@ -363,7 +363,7 @@ describe('plugin-commands (Hangar)', () => { describe('installHangarProject', () => { it('calls the typed plugin artifact command', async () => { - const { installHangarProject } = await import('../plugin-commands'); + const { installHangarProject } = await import('@/lib/plugin-commands'); await installHangarProject('https://hangar.papermc.io/plugin.jar', { serverId: 'server-1', relativeDir: 'plugins', @@ -381,7 +381,7 @@ describe('plugin-commands (Hangar)', () => { }); it('passes checksum metadata without adding download settings to the request', async () => { - const { installHangarProject } = await import('../plugin-commands'); + const { installHangarProject } = await import('@/lib/plugin-commands'); await installHangarProject( 'https://hangar.papermc.io/plugin.jar', { @@ -408,7 +408,7 @@ describe('plugin-commands (Hangar)', () => { }); it('rejects an invalid managed filename before invoking Rust', async () => { - const { installHangarProject } = await import('../plugin-commands'); + const { installHangarProject } = await import('@/lib/plugin-commands'); await expect( installHangarProject('https://hangar.papermc.io/plugin.jar', { serverId: 'server-1', diff --git a/src/lib/__tests__/plugin-commands-modrinth.test.ts b/tests/lib/plugin-commands-modrinth.test.ts similarity index 86% rename from src/lib/__tests__/plugin-commands-modrinth.test.ts rename to tests/lib/plugin-commands-modrinth.test.ts index 3153df38..7e513d97 100644 --- a/src/lib/__tests__/plugin-commands-modrinth.test.ts +++ b/tests/lib/plugin-commands-modrinth.test.ts @@ -5,8 +5,8 @@ const tauriInvokeMock = vi.fn(); const searchModrinthProjectsMock = vi.fn(); vi.mock('@tauri-apps/plugin-http', () => ({ fetch: fetchMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); -vi.mock('../adapters/plugin/modrinth-adapter', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); +vi.mock('@/lib/adapters/plugin/modrinth-adapter', () => ({ searchModrinthProjects: searchModrinthProjectsMock, })); @@ -39,7 +39,7 @@ describe('plugin-commands (Modrinth)', () => { project_type: 'plugin', }; searchModrinthProjectsMock.mockResolvedValueOnce({ hits: [mockHit], total_hits: 1 }); - const { searchModrinth } = await import('../plugin-commands'); + const { searchModrinth } = await import('@/lib/plugin-commands'); const result = await searchModrinth('worldedit', '', 0, 10); expect(searchModrinthProjectsMock).toHaveBeenCalledWith({ query: 'worldedit', @@ -53,7 +53,7 @@ describe('plugin-commands (Modrinth)', () => { it('propagates errors from the adapter', async () => { searchModrinthProjectsMock.mockRejectedValueOnce(new Error('API error')); - const { searchModrinth } = await import('../plugin-commands'); + const { searchModrinth } = await import('@/lib/plugin-commands'); await expect(searchModrinth('fail', '', 0, 10)).rejects.toThrow('API error'); }); }); @@ -62,7 +62,7 @@ describe('plugin-commands (Modrinth)', () => { it('returns the versions array from the API', async () => { const versions = [{ id: 'v1' }, { id: 'v2' }]; fetchMock.mockResolvedValueOnce(makeFetchResponse(versions)); - const { getModrinthVersions } = await import('../plugin-commands'); + const { getModrinthVersions } = await import('@/lib/plugin-commands'); const result = await getModrinthVersions('project-abc'); expect(result).toEqual(versions); expect(fetchMock.mock.calls[0][0]).toContain('project/project-abc/version'); @@ -70,7 +70,7 @@ describe('plugin-commands (Modrinth)', () => { it('throws when fetch fails', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse(null, false)); - const { getModrinthVersions } = await import('../plugin-commands'); + const { getModrinthVersions } = await import('@/lib/plugin-commands'); await expect(getModrinthVersions('bad-id')).rejects.toThrow('API error'); }); }); @@ -85,7 +85,7 @@ describe('plugin-commands (Modrinth)', () => { it('returns parsed version matching loader and minecraft version', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse([VERSION_RAW])); - const { getCompatibleModrinthVersion } = await import('../plugin-commands'); + const { getCompatibleModrinthVersion } = await import('@/lib/plugin-commands'); const result = await getCompatibleModrinthVersion({ projectId: 'p1', loader: 'paper', @@ -98,7 +98,7 @@ describe('plugin-commands (Modrinth)', () => { it('adds loaders and game_versions query params when provided', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse([VERSION_RAW])); - const { getCompatibleModrinthVersion } = await import('../plugin-commands'); + const { getCompatibleModrinthVersion } = await import('@/lib/plugin-commands'); await getCompatibleModrinthVersion({ projectId: 'p1', loader: 'paper', @@ -111,7 +111,7 @@ describe('plugin-commands (Modrinth)', () => { it('omits loaders param when loader is empty', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse([VERSION_RAW])); - const { getCompatibleModrinthVersion } = await import('../plugin-commands'); + const { getCompatibleModrinthVersion } = await import('@/lib/plugin-commands'); await getCompatibleModrinthVersion({ projectId: 'p1', loader: '', minecraftVersion: '1.21' }); const [url] = fetchMock.mock.calls[0]; expect(url).not.toContain('loaders='); @@ -119,7 +119,7 @@ describe('plugin-commands (Modrinth)', () => { it('returns null when payload is empty array', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse([])); - const { getCompatibleModrinthVersion } = await import('../plugin-commands'); + const { getCompatibleModrinthVersion } = await import('@/lib/plugin-commands'); const result = await getCompatibleModrinthVersion({ projectId: 'p1', loader: '', @@ -132,7 +132,7 @@ describe('plugin-commands (Modrinth)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse([{ id: 'v1', files: [], game_versions: [], dependencies: [] }]), ); - const { getCompatibleModrinthVersion } = await import('../plugin-commands'); + const { getCompatibleModrinthVersion } = await import('@/lib/plugin-commands'); const result = await getCompatibleModrinthVersion({ projectId: 'p1', loader: '', @@ -153,7 +153,7 @@ describe('plugin-commands (Modrinth)', () => { ], }; fetchMock.mockResolvedValueOnce(makeFetchResponse(raw)); - const { getModrinthVersionById } = await import('../plugin-commands'); + const { getModrinthVersionById } = await import('@/lib/plugin-commands'); const result = await getModrinthVersionById('v42'); expect(result?.id).toBe('v42'); expect(result?.fileName).toBe('plugin.jar'); @@ -165,7 +165,7 @@ describe('plugin-commands (Modrinth)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ id: 'v1', files: [], game_versions: [], dependencies: [] }), ); - const { getModrinthVersionById } = await import('../plugin-commands'); + const { getModrinthVersionById } = await import('@/lib/plugin-commands'); const result = await getModrinthVersionById('v1'); expect(result).toBeNull(); }); @@ -176,7 +176,7 @@ describe('plugin-commands (Modrinth)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ id: 'abc123', slug: 'worldedit', title: 'WorldEdit' }), ); - const { getModrinthProjectIdentity } = await import('../plugin-commands'); + const { getModrinthProjectIdentity } = await import('@/lib/plugin-commands'); const result = await getModrinthProjectIdentity('abc123'); expect(result?.id).toBe('abc123'); expect(result?.slug).toBe('worldedit'); @@ -185,7 +185,7 @@ describe('plugin-commands (Modrinth)', () => { it('returns null when id is missing', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ slug: 'worldedit', title: 'WorldEdit' })); - const { getModrinthProjectIdentity } = await import('../plugin-commands'); + const { getModrinthProjectIdentity } = await import('@/lib/plugin-commands'); const result = await getModrinthProjectIdentity('missing-id'); expect(result).toBeNull(); }); @@ -196,14 +196,14 @@ describe('plugin-commands (Modrinth)', () => { fetchMock.mockResolvedValueOnce( makeFetchResponse({ body: 'This is the project description.' }), ); - const { getModrinthProjectBody } = await import('../plugin-commands'); + const { getModrinthProjectBody } = await import('@/lib/plugin-commands'); const result = await getModrinthProjectBody('proj-1'); expect(result).toBe('This is the project description.'); }); it('returns null when body is empty string', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ body: ' ' })); - const { getModrinthProjectBody } = await import('../plugin-commands'); + const { getModrinthProjectBody } = await import('@/lib/plugin-commands'); const result = await getModrinthProjectBody('proj-1'); expect(result).toBeNull(); }); @@ -219,7 +219,7 @@ describe('plugin-commands (Modrinth)', () => { ], }), ); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await installModrinthProject('v1', 'worldedit-7.jar', { serverId: 'server-1', relativeDir: 'plugins', @@ -251,7 +251,7 @@ describe('plugin-commands (Modrinth)', () => { ], }), ); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await installModrinthProject('v1', 'worldedit-7.jar', { serverId: 'server-1', relativeDir: 'plugins', @@ -275,7 +275,7 @@ describe('plugin-commands (Modrinth)', () => { ], }), ); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await installModrinthProject('v1', 'missing-provider-file.jar', { serverId: 'server-1', relativeDir: 'plugins', @@ -294,7 +294,7 @@ describe('plugin-commands (Modrinth)', () => { it('throws when files array is empty', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ files: [] })); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await expect( installModrinthProject('v1', '', { serverId: 'server-1', @@ -306,7 +306,7 @@ describe('plugin-commands (Modrinth)', () => { it('throws when payload lacks files property', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ id: 'v1' })); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await expect( installModrinthProject('v1', '', { serverId: 'server-1', @@ -329,7 +329,7 @@ describe('plugin-commands (Modrinth)', () => { ], }), ); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await installModrinthProject('v1', 'provider.jar', { serverId: 'server-1', relativeDir: 'mods', @@ -352,7 +352,7 @@ describe('plugin-commands (Modrinth)', () => { }); it('rejects renderer-generated temporary target filenames before IPC', async () => { - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await expect( installModrinthProject('v1', 'provider.jar', { serverId: 'server-1', @@ -378,7 +378,7 @@ describe('plugin-commands (Modrinth)', () => { }), ); tauriInvokeMock.mockRejectedValueOnce(new Error('download failed')); - const { installModrinthProject } = await import('../plugin-commands'); + const { installModrinthProject } = await import('@/lib/plugin-commands'); await expect( installModrinthProject('v1', 'provider.jar', { diff --git a/src/lib/__tests__/plugin-commands-spigot.test.ts b/tests/lib/plugin-commands-spigot.test.ts similarity index 83% rename from src/lib/__tests__/plugin-commands-spigot.test.ts rename to tests/lib/plugin-commands-spigot.test.ts index b75ae1e4..67c2e4f1 100644 --- a/src/lib/__tests__/plugin-commands-spigot.test.ts +++ b/tests/lib/plugin-commands-spigot.test.ts @@ -5,8 +5,8 @@ const tauriInvokeMock = vi.fn(); const searchSpigotResourcesMock = vi.fn(); vi.mock('@tauri-apps/plugin-http', () => ({ fetch: fetchMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); -vi.mock('../adapters/plugin/spigot-adapter', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); +vi.mock('@/lib/adapters/plugin/spigot-adapter', () => ({ searchSpigotResources: searchSpigotResourcesMock, })); @@ -42,7 +42,7 @@ describe('plugin-commands (Spigot)', () => { external: false, }; searchSpigotResourcesMock.mockResolvedValueOnce([mockResource]); - const { searchSpigot } = await import('../plugin-commands'); + const { searchSpigot } = await import('@/lib/plugin-commands'); const result = await searchSpigot('essentials', 1, 25); expect(searchSpigotResourcesMock).toHaveBeenCalledWith({ query: 'essentials', @@ -55,7 +55,7 @@ describe('plugin-commands (Spigot)', () => { it('propagates errors from the adapter', async () => { searchSpigotResourcesMock.mockRejectedValueOnce(new Error('Spiget error')); - const { searchSpigot } = await import('../plugin-commands'); + const { searchSpigot } = await import('@/lib/plugin-commands'); await expect(searchSpigot('fail', 1, 10)).rejects.toThrow('Spiget error'); }); }); @@ -64,28 +64,28 @@ describe('plugin-commands (Spigot)', () => { it('returns decoded description when present', async () => { const encoded = base64Encode('This is a description'); fetchMock.mockResolvedValueOnce(makeFetchResponse({ description: encoded })); - const { getSpigotResourceBody } = await import('../plugin-commands'); + const { getSpigotResourceBody } = await import('@/lib/plugin-commands'); const result = await getSpigotResourceBody(12345); expect(result).toBe('This is a description'); }); it('returns null when description is empty', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ description: '' })); - const { getSpigotResourceBody } = await import('../plugin-commands'); + const { getSpigotResourceBody } = await import('@/lib/plugin-commands'); const result = await getSpigotResourceBody(12345); expect(result).toBeNull(); }); it('returns null when payload is not a record', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse(null)); - const { getSpigotResourceBody } = await import('../plugin-commands'); + const { getSpigotResourceBody } = await import('@/lib/plugin-commands'); const result = await getSpigotResourceBody(12345); expect(result).toBeNull(); }); it('calls the correct Spiget API endpoint', async () => { fetchMock.mockResolvedValueOnce(makeFetchResponse({ description: '' })); - const { getSpigotResourceBody } = await import('../plugin-commands'); + const { getSpigotResourceBody } = await import('@/lib/plugin-commands'); await getSpigotResourceBody(83557); const [url] = fetchMock.mock.calls[0]; expect(url).toContain('/v2/resources/83557'); @@ -94,7 +94,7 @@ describe('plugin-commands (Spigot)', () => { describe('downloadPlugin', () => { it('calls the typed plugin artifact command', async () => { - const { downloadPlugin } = await import('../plugin-commands'); + const { downloadPlugin } = await import('@/lib/plugin-commands'); await downloadPlugin({ serverId: 'server-1', relativePath: 'plugins/plugin.jar', @@ -116,7 +116,7 @@ describe('plugin-commands (Spigot)', () => { describe('installSpigotProject', () => { it('calls the typed command with the correct Spiget download URL', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await installSpigotProject(83557, { serverId: 'server-1', relativeDir: 'plugins', @@ -129,7 +129,7 @@ describe('plugin-commands (Spigot)', () => { }); it('adds version param to URL when versionId is provided', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await installSpigotProject( 83557, { serverId: 'server-1', relativeDir: 'plugins', fileName: 'essentials.jar' }, @@ -140,7 +140,7 @@ describe('plugin-commands (Spigot)', () => { }); it('omits version param when versionId is not provided', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await installSpigotProject(83557, { serverId: 'server-1', relativeDir: 'plugins', @@ -151,7 +151,7 @@ describe('plugin-commands (Spigot)', () => { }); it('omits version param when versionId is 0 or negative', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await installSpigotProject( 83557, { serverId: 'server-1', relativeDir: 'plugins', fileName: 'essentials.jar' }, @@ -162,7 +162,7 @@ describe('plugin-commands (Spigot)', () => { }); it('includes resourceId in eventId', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await installSpigotProject(83557, { serverId: 'server-1', relativeDir: 'plugins', @@ -173,7 +173,7 @@ describe('plugin-commands (Spigot)', () => { }); it('rejects an invalid managed filename before invoking Rust', async () => { - const { installSpigotProject } = await import('../plugin-commands'); + const { installSpigotProject } = await import('@/lib/plugin-commands'); await expect( installSpigotProject(83557, { serverId: 'server-1', diff --git a/src/lib/__tests__/plugin-install-errors.test.ts b/tests/lib/plugin-install-errors.test.ts similarity index 98% rename from src/lib/__tests__/plugin-install-errors.test.ts rename to tests/lib/plugin-install-errors.test.ts index 22207dba..8be84cd9 100644 --- a/src/lib/__tests__/plugin-install-errors.test.ts +++ b/tests/lib/plugin-install-errors.test.ts @@ -1,5 +1,8 @@ +import { + classifyPluginInstallFailure, + decodePluginInstallFailure, +} from '@/lib/plugin-install-errors'; import { describe, expect, it } from 'vitest'; -import { classifyPluginInstallFailure, decodePluginInstallFailure } from '../plugin-install-errors'; const expected = { 'unverified-artifact-blocked': { diff --git a/src/lib/__tests__/proxy-commands.test.ts b/tests/lib/proxy-commands.test.ts similarity index 97% rename from src/lib/__tests__/proxy-commands.test.ts rename to tests/lib/proxy-commands.test.ts index 8a52dc0b..033cbc89 100644 --- a/src/lib/__tests__/proxy-commands.test.ts +++ b/tests/lib/proxy-commands.test.ts @@ -1,6 +1,6 @@ +import { generateProxyConfig } from '@/lib/proxy-commands'; +import type { ProxyNetworkConfig } from '@/lib/proxy-commands'; import { describe, expect, it } from 'vitest'; -import { generateProxyConfig } from '../proxy-commands'; -import type { ProxyNetworkConfig } from '../proxy-commands'; const twoServers: ProxyNetworkConfig['servers'] = [ { name: 'lobby', address: '127.0.0.1', port: 25566 }, diff --git a/src/lib/__tests__/server-commands-crud.test.ts b/tests/lib/server-commands-crud.test.ts similarity index 87% rename from src/lib/__tests__/server-commands-crud.test.ts rename to tests/lib/server-commands-crud.test.ts index bc1a101e..99e54416 100644 --- a/src/lib/__tests__/server-commands-crud.test.ts +++ b/tests/lib/server-commands-crud.test.ts @@ -18,7 +18,7 @@ vi.mock('@tauri-apps/plugin-store', () => ({ }), })); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn(), })); @@ -52,7 +52,7 @@ describe('server-commands (CRUD)', () => { describe('getServers', () => { it('returns empty array when store is empty', async () => { getMock.mockResolvedValueOnce(null); - const { getServers } = await import('../server-commands'); + const { getServers } = await import('@/lib/server-commands'); const result = await getServers(); expect(result).toEqual([]); }); @@ -60,7 +60,7 @@ describe('server-commands (CRUD)', () => { it('returns server array from store', async () => { const servers = [{ id: 's1', name: 'Test', ...SERVER_BASE }]; getMock.mockResolvedValueOnce(servers); - const { getServers } = await import('../server-commands'); + const { getServers } = await import('@/lib/server-commands'); const result = await getServers(); expect(result).toEqual(servers); }); @@ -71,7 +71,7 @@ describe('server-commands (CRUD)', () => { const existing = [{ id: 's1', name: 'Existing', ...SERVER_BASE }]; const newServer = { id: 's2', name: 'New', ...SERVER_BASE, port: 25566 }; getMock.mockResolvedValueOnce(existing); - const { addServer } = await import('../server-commands'); + const { addServer } = await import('@/lib/server-commands'); await addServer(newServer); // existing is mutated by push, so assert with explicit array expect(setMock).toHaveBeenCalledWith('servers', [ @@ -84,7 +84,7 @@ describe('server-commands (CRUD)', () => { it('returns the added server', async () => { getMock.mockResolvedValueOnce(null); const server = { id: 's1', name: 'Test', ...SERVER_BASE }; - const { addServer } = await import('../server-commands'); + const { addServer } = await import('@/lib/server-commands'); const result = await addServer(server); expect(result).toBe(server); }); @@ -95,7 +95,7 @@ describe('server-commands (CRUD)', () => { const servers = [{ id: 's1', name: 'Old Name', ...SERVER_BASE }]; getMock.mockResolvedValueOnce(servers); const updated = { ...servers[0], name: 'New Name' }; - const { updateServer } = await import('../server-commands'); + const { updateServer } = await import('@/lib/server-commands'); await updateServer(updated); expect(setMock).toHaveBeenCalledWith('servers', [updated]); expect(saveMock).toHaveBeenCalled(); @@ -103,7 +103,7 @@ describe('server-commands (CRUD)', () => { it('does not save when id is not found', async () => { getMock.mockResolvedValueOnce([]); - const { updateServer } = await import('../server-commands'); + const { updateServer } = await import('@/lib/server-commands'); await updateServer({ id: 'nonexistent', name: 'X', ...SERVER_BASE }); expect(setMock).not.toHaveBeenCalled(); expect(saveMock).not.toHaveBeenCalled(); @@ -117,7 +117,7 @@ describe('server-commands (CRUD)', () => { { id: 's2', name: 'B', ...SERVER_BASE, port: 25566 }, ]; getMock.mockResolvedValueOnce(servers); - const { deleteServer } = await import('../server-commands'); + const { deleteServer } = await import('@/lib/server-commands'); const result = await deleteServer('s1'); expect(tauriInvokeMock).toHaveBeenCalledWith('is_server_running', { serverId: 's1' }); expect(tauriInvokeMock).toHaveBeenCalledWith('delete_managed_server_dir', { serverId: 's1' }); @@ -132,7 +132,7 @@ describe('server-commands (CRUD)', () => { { id: 's2', name: 'B', ...SERVER_BASE, path: '/app-data/servers/s2' }, ]; getMock.mockResolvedValueOnce(servers); - const { deleteServer } = await import('../server-commands'); + const { deleteServer } = await import('@/lib/server-commands'); await expect(deleteServer('s1')).resolves.toBe(true); @@ -156,7 +156,7 @@ describe('server-commands (CRUD)', () => { } return undefined; }); - const { deleteServer } = await import('../server-commands'); + const { deleteServer } = await import('@/lib/server-commands'); await expect(deleteServer('s1')).rejects.toThrow('permission denied'); @@ -168,7 +168,7 @@ describe('server-commands (CRUD)', () => { const servers = [{ id: 's1', name: 'A', ...SERVER_BASE, path: '/app-data/servers/s1' }]; getMock.mockResolvedValueOnce(servers); tauriInvokeMock.mockResolvedValueOnce(true); - const { deleteServer } = await import('../server-commands'); + const { deleteServer } = await import('@/lib/server-commands'); await expect(deleteServer('s1')).rejects.toThrow('Cannot delete a running server'); @@ -183,7 +183,7 @@ describe('server-commands (CRUD)', () => { it('returns false without saving when server is not found', async () => { getMock.mockResolvedValueOnce([]); - const { deleteServer } = await import('../server-commands'); + const { deleteServer } = await import('@/lib/server-commands'); await expect(deleteServer('missing')).resolves.toBe(false); @@ -196,7 +196,7 @@ describe('server-commands (CRUD)', () => { describe('getServerTemplates', () => { it('returns empty array when store is empty', async () => { getMock.mockResolvedValueOnce(null); - const { getServerTemplates } = await import('../server-commands'); + const { getServerTemplates } = await import('@/lib/server-commands'); const result = await getServerTemplates(); expect(result).toEqual([]); }); @@ -213,7 +213,7 @@ describe('server-commands (CRUD)', () => { }, ]; getMock.mockResolvedValueOnce(templates); - const { getServerTemplates } = await import('../server-commands'); + const { getServerTemplates } = await import('@/lib/server-commands'); const result = await getServerTemplates(); expect(result).toEqual(templates); }); @@ -230,7 +230,7 @@ describe('server-commands (CRUD)', () => { port: 25565, memory: 2048, }; - const { saveServerTemplate } = await import('../server-commands'); + const { saveServerTemplate } = await import('@/lib/server-commands'); const result = await saveServerTemplate(template); expect(setMock).toHaveBeenCalledWith('serverTemplates', [template]); expect(saveMock).toHaveBeenCalled(); @@ -243,7 +243,7 @@ describe('server-commands (CRUD)', () => { ]; getMock.mockResolvedValueOnce(existing); const updated = { ...existing[0], name: 'Updated' }; - const { saveServerTemplate } = await import('../server-commands'); + const { saveServerTemplate } = await import('@/lib/server-commands'); await saveServerTemplate(updated); expect(setMock).toHaveBeenCalledWith('serverTemplates', [updated]); }); @@ -256,7 +256,7 @@ describe('server-commands (CRUD)', () => { { id: 't2', name: 'B', version: '1.21', software: 'paper', port: 25566, memory: 2048 }, ]; getMock.mockResolvedValueOnce(templates); - const { deleteServerTemplate } = await import('../server-commands'); + const { deleteServerTemplate } = await import('@/lib/server-commands'); await deleteServerTemplate('t1'); expect(setMock).toHaveBeenCalledWith('serverTemplates', [templates[1]]); expect(saveMock).toHaveBeenCalled(); diff --git a/src/lib/__tests__/server-commands.test.ts b/tests/lib/server-commands.test.ts similarity index 87% rename from src/lib/__tests__/server-commands.test.ts rename to tests/lib/server-commands.test.ts index 31ec8d28..81023581 100644 --- a/src/lib/__tests__/server-commands.test.ts +++ b/tests/lib/server-commands.test.ts @@ -7,7 +7,7 @@ vi.mock('@tauri-apps/api/path', () => ({ appDataDir: appDataDirMock, })); -vi.mock('../tauri-api', () => ({ +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn(), })); @@ -31,7 +31,7 @@ describe('server-commands', () => { it('startServer invokes start_server with correct args (no jvmExtraArgs)', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { startServer } = await import('../server-commands'); + const { startServer } = await import('@/lib/server-commands'); await startServer('server-1', 'C:\\Java\\bin\\java.exe', 4096, 'server.jar'); @@ -47,7 +47,7 @@ describe('server-commands', () => { it('startServer passes jvmExtraArgs when provided', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { startServer } = await import('../server-commands'); + const { startServer } = await import('@/lib/server-commands'); await startServer('server-1', '/usr/bin/java', 2048, 'server.jar', '-XX:+UseG1GC'); @@ -63,7 +63,7 @@ describe('server-commands', () => { it('stopServer invokes stop_server with serverId', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { stopServer } = await import('../server-commands'); + const { stopServer } = await import('@/lib/server-commands'); await stopServer('server-1'); @@ -75,7 +75,7 @@ describe('server-commands', () => { it('sendCommand invokes send_command with serverId and command', async () => { tauriInvokeMock.mockResolvedValueOnce(undefined); - const { sendCommand } = await import('../server-commands'); + const { sendCommand } = await import('@/lib/server-commands'); await sendCommand('server-1', 'stop'); @@ -88,7 +88,7 @@ describe('server-commands', () => { it('isServerRunning invokes is_server_running and returns boolean', async () => { tauriInvokeMock.mockResolvedValueOnce(true); - const { isServerRunning } = await import('../server-commands'); + const { isServerRunning } = await import('@/lib/server-commands'); const result = await isServerRunning('server-1'); diff --git a/src/lib/__tests__/tauri-api.test.ts b/tests/lib/tauri-api.test.ts similarity index 86% rename from src/lib/__tests__/tauri-api.test.ts rename to tests/lib/tauri-api.test.ts index e741015a..4f6ecc3f 100644 --- a/src/lib/__tests__/tauri-api.test.ts +++ b/tests/lib/tauri-api.test.ts @@ -19,7 +19,7 @@ describe('tauriInvoke', () => { it('allows registered command and calls invoke', async () => { invokeMock.mockResolvedValueOnce(true); - const { tauriInvoke } = await import('../tauri-api'); + const { tauriInvoke } = await import('@/lib/tauri-api'); await expect(tauriInvoke('is_server_running', { serverId: 'test-server' })).resolves.toBe(true); @@ -29,7 +29,7 @@ describe('tauriInvoke', () => { }); it("throws 'Blocked tauri command' for unregistered command", async () => { - const { tauriInvoke } = await import('../tauri-api'); + const { tauriInvoke } = await import('@/lib/tauri-api'); await expect(tauriInvoke('dangerous_command', {})).rejects.toThrow( 'Blocked tauri command: dangerous_command', @@ -41,7 +41,7 @@ describe('tauriInvoke', () => { it('wraps invoke error with [Tauri] prefix', async () => { invokeMock.mockRejectedValueOnce(new Error('process failed')); - const { tauriInvoke } = await import('../tauri-api'); + const { tauriInvoke } = await import('@/lib/tauri-api'); await expect(tauriInvoke('start_server', { serverId: 'server-1' })).rejects.toThrow( '[Tauri] start_server failed: process failed', diff --git a/src/lib/__tests__/tauri-capabilities.test.ts b/tests/lib/tauri-capabilities.test.ts similarity index 100% rename from src/lib/__tests__/tauri-capabilities.test.ts rename to tests/lib/tauri-capabilities.test.ts diff --git a/src/lib/__tests__/toast-entry-style.test.ts b/tests/lib/toast-entry-style.test.ts similarity index 100% rename from src/lib/__tests__/toast-entry-style.test.ts rename to tests/lib/toast-entry-style.test.ts diff --git a/src/lib/__tests__/update-commands.test.ts b/tests/lib/update-commands.test.ts similarity index 84% rename from src/lib/__tests__/update-commands.test.ts rename to tests/lib/update-commands.test.ts index 62647340..4ec86a48 100644 --- a/src/lib/__tests__/update-commands.test.ts +++ b/tests/lib/update-commands.test.ts @@ -9,8 +9,8 @@ const logErrorMock = vi.fn(); vi.mock('@tauri-apps/api/app', () => ({ getVersion: getVersionMock })); vi.mock('@tauri-apps/plugin-process', () => ({ relaunch: relaunchMock })); vi.mock('@tauri-apps/plugin-updater', () => ({ check: checkMock })); -vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); -vi.mock('../error-utils', () => ({ logError: logErrorMock })); +vi.mock('@/lib/tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); +vi.mock('@/lib/error-utils', () => ({ logError: logErrorMock })); vi.mock('@/i18n', () => ({ getTranslation: vi.fn().mockReturnValue((key: string) => key), })); @@ -36,7 +36,7 @@ describe('getAppVersion', () => { it('returns version string from getVersion', async () => { getVersionMock.mockResolvedValue('2.0.55'); - const { getAppVersion } = await import('../update-commands'); + const { getAppVersion } = await import('@/lib/update-commands'); const result = await getAppVersion(); expect(result).toBe('2.0.55'); }); @@ -46,7 +46,7 @@ describe('checkForUpdates', () => { it('returns available=true with version and body when update exists', async () => { checkMock.mockResolvedValue(makeUpdate({ version: '2.1.0', body: 'New features' })); - const { checkForUpdates } = await import('../update-commands'); + const { checkForUpdates } = await import('@/lib/update-commands'); const result = await checkForUpdates(); expect(result).toEqual({ available: true, version: '2.1.0', body: 'New features' }); }); @@ -54,7 +54,7 @@ describe('checkForUpdates', () => { it('returns available=false when no update', async () => { checkMock.mockResolvedValue(null); - const { checkForUpdates } = await import('../update-commands'); + const { checkForUpdates } = await import('@/lib/update-commands'); const result = await checkForUpdates(); expect(result).toEqual({ available: false }); }); @@ -62,7 +62,7 @@ describe('checkForUpdates', () => { it('returns available=false with error string when check throws', async () => { checkMock.mockRejectedValue(new Error('network timeout')); - const { checkForUpdates } = await import('../update-commands'); + const { checkForUpdates } = await import('@/lib/update-commands'); const result = await checkForUpdates(); expect(result.available).toBe(false); expect(result.error).toContain('network timeout'); @@ -72,7 +72,7 @@ describe('checkForUpdates', () => { it('normalizes signature verification error', async () => { checkMock.mockRejectedValue(new Error('signature verification failed')); - const { checkForUpdates } = await import('../update-commands'); + const { checkForUpdates } = await import('@/lib/update-commands'); const result = await checkForUpdates(); expect(result.error).toContain('errors.updateSignatureVerificationFailed'); }); @@ -80,7 +80,7 @@ describe('checkForUpdates', () => { it('returns undefined body when update body is null', async () => { checkMock.mockResolvedValue(makeUpdate({ body: null })); - const { checkForUpdates } = await import('../update-commands'); + const { checkForUpdates } = await import('@/lib/update-commands'); const result = await checkForUpdates(); expect(result.body).toBeUndefined(); }); @@ -90,7 +90,7 @@ describe('canUpdateApp', () => { it('returns UpdateCheckResult from tauriInvoke', async () => { tauriInvokeMock.mockResolvedValue({ can_update: true }); - const { canUpdateApp } = await import('../update-commands'); + const { canUpdateApp } = await import('@/lib/update-commands'); const result = await canUpdateApp(); expect(result).toEqual({ can_update: true }); expect(tauriInvokeMock).toHaveBeenCalledWith('can_update_app'); @@ -99,7 +99,7 @@ describe('canUpdateApp', () => { it('returns check_failed when tauriInvoke throws', async () => { tauriInvokeMock.mockRejectedValue(new Error('ipc error')); - const { canUpdateApp } = await import('../update-commands'); + const { canUpdateApp } = await import('@/lib/update-commands'); const result = await canUpdateApp(); expect(result).toEqual({ can_update: false, reason: 'check_failed' }); expect(logErrorMock).toHaveBeenCalled(); @@ -110,7 +110,7 @@ describe('downloadAndInstallUpdate', () => { it('throws when no update is available', async () => { checkMock.mockResolvedValue(null); - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow('No update available'); }); @@ -122,7 +122,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce('/Applications/MC-Vector.app'); // get_app_location (unused) relaunchMock.mockResolvedValue(undefined); - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await downloadAndInstallUpdate(); expect(update.downloadAndInstall).toHaveBeenCalled(); @@ -136,7 +136,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce({ can_update: false, reason: 'read_only' }) // can_update_app .mockResolvedValueOnce('/Downloads/MC-Vector.app'); // get_app_location - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow( /read-only location|errors\.updateReadOnlyLocation/, ); @@ -149,7 +149,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce({ can_update: false, reason: 'permission_denied' }) // can_update_app .mockResolvedValueOnce('/Applications/MC-Vector.app'); // get_app_location - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow( /permissions to update|errors\.updatePermissionDenied/, ); @@ -162,7 +162,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce({ can_update: false, reason: 'unknown' }) // can_update_app .mockResolvedValueOnce('/Applications/MC-Vector.app'); // get_app_location - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow( /Cannot apply update|errors\.updateCannotApply/, ); @@ -178,7 +178,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce({ can_update: true }) // can_update_app .mockResolvedValueOnce('/Downloads/MC-Vector.app'); // get_app_location in catch - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow( /read-only location|errors\.updateReadOnlyLocation/, ); @@ -194,7 +194,7 @@ describe('downloadAndInstallUpdate', () => { .mockResolvedValueOnce({ can_update: true }) // can_update_app .mockResolvedValueOnce('/Downloads/MC-Vector.app'); // get_app_location in catch - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await expect(downloadAndInstallUpdate()).rejects.toThrow( /read-only location|errors\.updateReadOnlyLocation/, ); @@ -215,7 +215,7 @@ describe('downloadAndInstallUpdate', () => { tauriInvokeMock.mockResolvedValue({ can_update: true }); relaunchMock.mockResolvedValue(undefined); - const { downloadAndInstallUpdate } = await import('../update-commands'); + const { downloadAndInstallUpdate } = await import('@/lib/update-commands'); await downloadAndInstallUpdate(onProgress); expect(onProgress).toHaveBeenCalledWith(500, 1000); diff --git a/src/lib/__tests__/version-commands.test.ts b/tests/lib/version-commands.test.ts similarity index 89% rename from src/lib/__tests__/version-commands.test.ts rename to tests/lib/version-commands.test.ts index a8848f86..95c527f6 100644 --- a/src/lib/__tests__/version-commands.test.ts +++ b/tests/lib/version-commands.test.ts @@ -38,7 +38,7 @@ describe('resolve jar URLs from official sources', () => { ]), ); - const { resolveLatestJarUrl } = await import('../version-commands'); + const { resolveLatestJarUrl } = await import('@/lib/version-commands'); const result = await resolveLatestJarUrl('Paper', '1.21'); expect(result).toEqual({ @@ -60,7 +60,7 @@ describe('resolve jar URLs from official sources', () => { }), ); - const { resolveLatestJarUrl } = await import('../version-commands'); + const { resolveLatestJarUrl } = await import('@/lib/version-commands'); const result = await resolveLatestJarUrl('Paper', '1.21'); expect(result?.downloadUrl).toContain('https://api.papermc.io/v2/projects/paper/'); @@ -74,7 +74,7 @@ describe('resolve jar URLs from official sources', () => { .mockRejectedValueOnce(new Error('url not allowed on the configured scope')) .mockResolvedValueOnce(makeResponse({}, 410)); - const { resolveRequestedJarUrl } = await import('../version-commands'); + const { resolveRequestedJarUrl } = await import('@/lib/version-commands'); await expect(resolveRequestedJarUrl('Paper', '26.2')).rejects.toThrow('HTTP 410'); }); @@ -94,7 +94,7 @@ describe('resolve jar URLs from official sources', () => { ]), ); - const { resolveLatestJarUrl } = await import('../version-commands'); + const { resolveLatestJarUrl } = await import('@/lib/version-commands'); const result = await resolveLatestJarUrl('LeafMC', '1.21'); expect(fetchMock.mock.calls.every(([url]) => url.includes('api.leafmc.one'))).toBe(true); @@ -124,7 +124,7 @@ describe('resolve jar URLs from official sources', () => { }), ); - const { resolveLatestJarUrl } = await import('../version-commands'); + const { resolveLatestJarUrl } = await import('@/lib/version-commands'); const result = await resolveLatestJarUrl('Vanilla', ''); expect(result?.latestVersion).toBe('1.21.10'); @@ -144,7 +144,7 @@ describe('resolve jar URLs from official sources', () => { ) .mockResolvedValueOnce(makeResponse([{ version: '1.0.1', stable: true }])); - const { resolveLatestJarUrl } = await import('../version-commands'); + const { resolveLatestJarUrl } = await import('@/lib/version-commands'); const result = await resolveLatestJarUrl('Fabric', '1.21.4'); expect(result?.downloadUrl).toBe( @@ -167,7 +167,7 @@ describe('resolve jar URLs from official sources', () => { ]), ); - const { resolveRequestedJarUrl } = await import('../version-commands'); + const { resolveRequestedJarUrl } = await import('@/lib/version-commands'); const result = await resolveRequestedJarUrl('Paper', '1.21.10'); expect(fetchMock.mock.calls[0][0]).toContain('/projects/paper/versions/1.21.10/builds'); @@ -177,7 +177,7 @@ describe('resolve jar URLs from official sources', () => { it.each(['Spigot', 'Forge', 'Velocity', 'Waterfall', 'BungeeCord'])( 'does not auto-download unsupported software: %s', async (software) => { - const { resolveRequestedJarUrl } = await import('../version-commands'); + const { resolveRequestedJarUrl } = await import('@/lib/version-commands'); await expect(resolveRequestedJarUrl(software, '1.21')).resolves.toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); }, @@ -185,7 +185,7 @@ describe('resolve jar URLs from official sources', () => { it('returns null for an unavailable version', async () => { fetchMock.mockResolvedValueOnce(makeResponse([])); - const { resolveRequestedJarUrl } = await import('../version-commands'); + const { resolveRequestedJarUrl } = await import('@/lib/version-commands'); await expect(resolveRequestedJarUrl('Paper', '9.99')).resolves.toBeNull(); }); }); diff --git a/src/test-mocks/tauri-api-app.ts b/tests/mocks/tauri-api-app.ts similarity index 100% rename from src/test-mocks/tauri-api-app.ts rename to tests/mocks/tauri-api-app.ts diff --git a/src/test-mocks/tauri-api-core.ts b/tests/mocks/tauri-api-core.ts similarity index 100% rename from src/test-mocks/tauri-api-core.ts rename to tests/mocks/tauri-api-core.ts diff --git a/src/test-mocks/tauri-api-event.ts b/tests/mocks/tauri-api-event.ts similarity index 100% rename from src/test-mocks/tauri-api-event.ts rename to tests/mocks/tauri-api-event.ts diff --git a/src/test-mocks/tauri-api-path.ts b/tests/mocks/tauri-api-path.ts similarity index 100% rename from src/test-mocks/tauri-api-path.ts rename to tests/mocks/tauri-api-path.ts diff --git a/src/test-mocks/tauri-api-webview-window.ts b/tests/mocks/tauri-api-webview-window.ts similarity index 100% rename from src/test-mocks/tauri-api-webview-window.ts rename to tests/mocks/tauri-api-webview-window.ts diff --git a/src/test-mocks/tauri-api-window.ts b/tests/mocks/tauri-api-window.ts similarity index 100% rename from src/test-mocks/tauri-api-window.ts rename to tests/mocks/tauri-api-window.ts diff --git a/src/test-mocks/tauri-plugin-clipboard.ts b/tests/mocks/tauri-plugin-clipboard.ts similarity index 100% rename from src/test-mocks/tauri-plugin-clipboard.ts rename to tests/mocks/tauri-plugin-clipboard.ts diff --git a/src/test-mocks/tauri-plugin-dialog.ts b/tests/mocks/tauri-plugin-dialog.ts similarity index 100% rename from src/test-mocks/tauri-plugin-dialog.ts rename to tests/mocks/tauri-plugin-dialog.ts diff --git a/src/test-mocks/tauri-plugin-fs.ts b/tests/mocks/tauri-plugin-fs.ts similarity index 100% rename from src/test-mocks/tauri-plugin-fs.ts rename to tests/mocks/tauri-plugin-fs.ts diff --git a/src/test-mocks/tauri-plugin-global-shortcut.ts b/tests/mocks/tauri-plugin-global-shortcut.ts similarity index 100% rename from src/test-mocks/tauri-plugin-global-shortcut.ts rename to tests/mocks/tauri-plugin-global-shortcut.ts diff --git a/src/test-mocks/tauri-plugin-http.ts b/tests/mocks/tauri-plugin-http.ts similarity index 100% rename from src/test-mocks/tauri-plugin-http.ts rename to tests/mocks/tauri-plugin-http.ts diff --git a/src/test-mocks/tauri-plugin-notification.ts b/tests/mocks/tauri-plugin-notification.ts similarity index 100% rename from src/test-mocks/tauri-plugin-notification.ts rename to tests/mocks/tauri-plugin-notification.ts diff --git a/src/test-mocks/tauri-plugin-opener.ts b/tests/mocks/tauri-plugin-opener.ts similarity index 100% rename from src/test-mocks/tauri-plugin-opener.ts rename to tests/mocks/tauri-plugin-opener.ts diff --git a/src/test-mocks/tauri-plugin-os.ts b/tests/mocks/tauri-plugin-os.ts similarity index 100% rename from src/test-mocks/tauri-plugin-os.ts rename to tests/mocks/tauri-plugin-os.ts diff --git a/src/test-mocks/tauri-plugin-process.ts b/tests/mocks/tauri-plugin-process.ts similarity index 100% rename from src/test-mocks/tauri-plugin-process.ts rename to tests/mocks/tauri-plugin-process.ts diff --git a/src/test-mocks/tauri-plugin-store.ts b/tests/mocks/tauri-plugin-store.ts similarity index 100% rename from src/test-mocks/tauri-plugin-store.ts rename to tests/mocks/tauri-plugin-store.ts diff --git a/src/test-mocks/tauri-plugin-updater.ts b/tests/mocks/tauri-plugin-updater.ts similarity index 100% rename from src/test-mocks/tauri-plugin-updater.ts rename to tests/mocks/tauri-plugin-updater.ts diff --git a/src/test-mocks/tauri-plugin-window-state.ts b/tests/mocks/tauri-plugin-window-state.ts similarity index 100% rename from src/test-mocks/tauri-plugin-window-state.ts rename to tests/mocks/tauri-plugin-window-state.ts diff --git a/src/renderer/components/__tests__/AppFeedbackProvider.test.tsx b/tests/renderer/components/AppFeedbackProvider.test.tsx similarity index 97% rename from src/renderer/components/__tests__/AppFeedbackProvider.test.tsx rename to tests/renderer/components/AppFeedbackProvider.test.tsx index 9078f202..bfcc6359 100644 --- a/src/renderer/components/__tests__/AppFeedbackProvider.test.tsx +++ b/tests/renderer/components/AppFeedbackProvider.test.tsx @@ -1,7 +1,7 @@ +import { AppFeedbackProvider, useAppFeedback } from '@/renderer/components/AppFeedbackProvider'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { toast } from 'sonner'; import { describe, expect, it, vi } from 'vitest'; -import { AppFeedbackProvider, useAppFeedback } from '../AppFeedbackProvider'; function FeedbackTrigger() { const { notifySuccess, openDialog } = useAppFeedback(); diff --git a/src/renderer/components/__tests__/FileEditorWorkspace.test.tsx b/tests/renderer/components/FileEditorWorkspace.test.tsx similarity index 95% rename from src/renderer/components/__tests__/FileEditorWorkspace.test.tsx rename to tests/renderer/components/FileEditorWorkspace.test.tsx index e38042db..c597a6ae 100644 --- a/src/renderer/components/__tests__/FileEditorWorkspace.test.tsx +++ b/tests/renderer/components/FileEditorWorkspace.test.tsx @@ -1,7 +1,7 @@ +import type { Translate } from '@/i18n'; +import FileEditorWorkspace from '@/renderer/components/FileEditorWorkspace'; import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { Translate } from '../../../i18n'; -import FileEditorWorkspace from '../FileEditorWorkspace'; vi.mock('@monaco-editor/react', () => ({ Editor: ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => ( diff --git a/src/renderer/components/__tests__/PluginBrowser.integration.test.tsx b/tests/renderer/components/PluginBrowser.integration.test.tsx similarity index 92% rename from src/renderer/components/__tests__/PluginBrowser.integration.test.tsx rename to tests/renderer/components/PluginBrowser.integration.test.tsx index f69429ce..331db932 100644 --- a/src/renderer/components/__tests__/PluginBrowser.integration.test.tsx +++ b/tests/renderer/components/PluginBrowser.integration.test.tsx @@ -1,9 +1,9 @@ +import { useI18nStore } from '@/i18n'; +import { AppFeedbackProvider } from '@/renderer/components/AppFeedbackProvider'; +import PluginBrowser from '@/renderer/components/PluginBrowser'; +import { useUiStore } from '@/store/uiStore'; import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useI18nStore } from '../../../i18n'; -import { useUiStore } from '../../../store/uiStore'; -import { AppFeedbackProvider } from '../AppFeedbackProvider'; -import PluginBrowser from '../PluginBrowser'; const { fileCommands, pluginCommands, queryState } = vi.hoisted(() => ({ fileCommands: { @@ -46,9 +46,9 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: vi.fn().mockResolvedValue(undefined), })); -vi.mock('../../../lib/file-commands', () => fileCommands); -vi.mock('../../../lib/plugin-commands', () => pluginCommands); -vi.mock('../../../lib/error-utils', () => ({ logError: vi.fn() })); +vi.mock('@/lib/file-commands', () => fileCommands); +vi.mock('@/lib/plugin-commands', () => pluginCommands); +vi.mock('@/lib/error-utils', () => ({ logError: vi.fn() })); const incompatibleItem = { id: 'spigot-legacy', diff --git a/src/renderer/components/__tests__/PluginBrowser.test.tsx b/tests/renderer/components/PluginBrowser.test.tsx similarity index 89% rename from src/renderer/components/__tests__/PluginBrowser.test.tsx rename to tests/renderer/components/PluginBrowser.test.tsx index c47c7ae8..a278e401 100644 --- a/src/renderer/components/__tests__/PluginBrowser.test.tsx +++ b/tests/renderer/components/PluginBrowser.test.tsx @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; -import { classifyPluginInstallFailure } from '../../../lib/plugin-install-errors'; +import { classifyPluginInstallFailure } from '@/lib/plugin-install-errors'; +import { createPluginInstallTarget } from '@/renderer/components/plugin-install-target'; import { pluginInstallFailurePresentation, shouldBlockPluginInstall, -} from '../../shared/plugin-install-policy'; -import { createPluginInstallTarget } from '../plugin-install-target'; +} from '@/renderer/shared/plugin-install-policy'; +import { describe, expect, it } from 'vitest'; describe('PluginBrowser install destination contract', () => { it('targets plugins for Paper servers without generating a temporary filename', () => { diff --git a/src/renderer/components/__tests__/SettingsWindow.test.tsx b/tests/renderer/components/SettingsWindow.test.tsx similarity index 85% rename from src/renderer/components/__tests__/SettingsWindow.test.tsx rename to tests/renderer/components/SettingsWindow.test.tsx index baab8b85..c816300f 100644 --- a/src/renderer/components/__tests__/SettingsWindow.test.tsx +++ b/tests/renderer/components/SettingsWindow.test.tsx @@ -1,23 +1,23 @@ +import { useI18nStore } from '@/i18n'; +import SettingsWindow from '@/renderer/components/SettingsWindow'; +import { useSettingsStore } from '@/store/settingsStore'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useI18nStore } from '../../../i18n'; -import { useSettingsStore } from '../../../store/settingsStore'; -import SettingsWindow from '../SettingsWindow'; const { saveAppSettingsMock } = vi.hoisted(() => ({ saveAppSettingsMock: vi.fn(), })); -vi.mock('../../../lib/config-commands', () => ({ +vi.mock('@/lib/config-commands', () => ({ saveAppSettings: saveAppSettingsMock, })); -vi.mock('../../../lib/update-commands', () => ({ +vi.mock('@/lib/update-commands', () => ({ checkForUpdates: vi.fn(), downloadAndInstallUpdate: vi.fn(), })); -vi.mock('../../../lib/error-utils', () => ({ +vi.mock('@/lib/error-utils', () => ({ logError: vi.fn(), })); diff --git a/src/renderer/components/__tests__/plugin-search-query.test.tsx b/tests/renderer/components/plugin-search-query.test.tsx similarity index 93% rename from src/renderer/components/__tests__/plugin-search-query.test.tsx rename to tests/renderer/components/plugin-search-query.test.tsx index 2a2b8073..fb79f3f2 100644 --- a/src/renderer/components/__tests__/plugin-search-query.test.tsx +++ b/tests/renderer/components/plugin-search-query.test.tsx @@ -3,16 +3,19 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const searchSelectedApiMock = vi.fn(); - interface SearchResult { items: string[]; } +const searchSelectedApiMock = vi.fn( + ({ query, signal }: { api: string; query: string; signal: AbortSignal }): Promise => + Promise.resolve({ items: [`${query}:${signal.aborted}`] }), +); + function SearchQueryHarness() { const [query, setQuery] = useState('initial'); const lastSuccessfulData = useRef(undefined); - const searchQuery = useQuery({ + const searchQuery = useQuery({ queryKey: ['plugin-search', 'Modrinth', query, 'server-1', 'Paper', '1.21.4'] as const, queryFn: ({ signal }) => searchSelectedApiMock({ api: 'Modrinth', query, signal }), retry: 1, diff --git a/src/renderer/components/ui/Progress.test.tsx b/tests/renderer/components/ui/Progress.test.tsx similarity index 94% rename from src/renderer/components/ui/Progress.test.tsx rename to tests/renderer/components/ui/Progress.test.tsx index 68d90f0e..0593e0cb 100644 --- a/src/renderer/components/ui/Progress.test.tsx +++ b/tests/renderer/components/ui/Progress.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { Progress } from './Progress'; +import { Progress } from '@/renderer/components/ui/Progress'; describe('Progress', () => { it('clamps values to the 0-100 range', () => { diff --git a/src/renderer/shared/__tests__/auto-backup.test.ts b/tests/renderer/shared/auto-backup.test.ts similarity index 97% rename from src/renderer/shared/__tests__/auto-backup.test.ts rename to tests/renderer/shared/auto-backup.test.ts index 65e33ff7..bd87c1ea 100644 --- a/src/renderer/shared/__tests__/auto-backup.test.ts +++ b/tests/renderer/shared/auto-backup.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; import { buildAutoBackupName, buildTimeBasedAutoBackupKey, resolveAutoBackupScheduleType, -} from '../auto-backup'; -import type { MinecraftServer } from '../server declaration'; +} from '@/renderer/shared/auto-backup'; +import type { MinecraftServer } from '@/renderer/shared/server declaration'; +import { describe, expect, it } from 'vitest'; function makeServer(overrides: Partial = {}): MinecraftServer { return { diff --git a/src/renderer/shared/__tests__/feedback.test.ts b/tests/renderer/shared/feedback.test.ts similarity index 93% rename from src/renderer/shared/__tests__/feedback.test.ts rename to tests/renderer/shared/feedback.test.ts index 8f37b083..98ccfd64 100644 --- a/src/renderer/shared/__tests__/feedback.test.ts +++ b/tests/renderer/shared/feedback.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { resolveFeedbackPresentation } from '../feedback'; +import { resolveFeedbackPresentation } from '@/renderer/shared/feedback'; import { pluginInstallFailurePresentation, shouldBlockPluginInstall, -} from '../plugin-install-policy'; +} from '@/renderer/shared/plugin-install-policy'; +import { describe, expect, it } from 'vitest'; describe('feedback presentation policy', () => { it('keeps successful and informational completion feedback as toast', () => { diff --git a/src/test/setup.ts b/tests/setup.ts similarity index 100% rename from src/test/setup.ts rename to tests/setup.ts diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 00000000..d3a24f32 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "types": ["vite/client", "vitest/globals", "node", "@playwright/test"], + "noEmit": true + }, + "include": ["tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/vite.config.ts b/vite.config.ts index 4b4c2128..84d24fb9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ const devHost = tauriDevHost ?? process.env.HOST; const isPlaywright = process.env.VITE_PLAYWRIGHT === 'true'; function mockPath(name: string) { - return fileURLToPath(new URL(`./src/test-mocks/${name}`, import.meta.url)); + return fileURLToPath(new URL(`./tests/mocks/${name}`, import.meta.url)); } const playwrightAliases = isPlaywright diff --git a/vitest.config.ts b/vitest.config.ts index ab72c061..978a2ce3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,14 +7,14 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - setupFiles: ["./src/test/setup.ts"], + setupFiles: ["./tests/setup.ts"], include: [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/**/*.spec.ts", - "src/**/*.spec.tsx", + "tests/**/*.test.ts", + "tests/**/*.test.tsx", + "tests/**/*.spec.ts", + "tests/**/*.spec.tsx", ], - exclude: ["node_modules", "dist", "src-tauri", "e2e"], + exclude: ["node_modules", "dist", "src-tauri", "tests/e2e/**"], }, resolve: { alias: { From a66807a34152251cabc7146640a4f4d615f33c73 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Wed, 2 Sep 2026 10:44:06 +0900 Subject: [PATCH 02/16] test: move Playwright E2E into dedicated tests directory Move Playwright specs under tests/e2e and provide a page-scoped runtime for deterministic Tauri, provider, store, filesystem, dialog, and event behavior. Reject unregistered mock commands and record calls for UI-level contract assertions. --- playwright.config.ts | 11 +- .../e2e}/capture-theme-screenshots.spec.ts | 12 +- .../tests => tests/e2e}/navigation.spec.ts | 23 +- .../e2e}/plugin-browser.spec.ts | 17 +- .../e2e}/server-lifecycle.spec.ts | 27 +- .../tests => tests/e2e}/server-modal.spec.ts | 25 +- .../tests => tests/e2e}/settings.spec.ts | 19 +- .../tests => tests/e2e}/sidebar.spec.ts | 19 +- tests/e2e/support/app-fixture.ts | 134 ++++++++ tests/e2e/support/e2e-runtime.ts | 303 ++++++++++++++++++ tests/mocks/tauri-api-core.ts | 293 ++++++++++++++++- tests/mocks/tauri-api-event.ts | 46 ++- tests/mocks/tauri-plugin-dialog.ts | 33 +- tests/mocks/tauri-plugin-fs.ts | 79 +++-- tests/mocks/tauri-plugin-http.ts | 164 ++++++++-- tests/mocks/tauri-plugin-opener.ts | 19 +- tests/mocks/tauri-plugin-store.ts | 161 ++++++++-- 17 files changed, 1190 insertions(+), 195 deletions(-) rename {playwright/tests => tests/e2e}/capture-theme-screenshots.spec.ts (89%) rename {playwright/tests => tests/e2e}/navigation.spec.ts (61%) rename {playwright/tests => tests/e2e}/plugin-browser.spec.ts (83%) rename {playwright/tests => tests/e2e}/server-lifecycle.spec.ts (79%) rename {playwright/tests => tests/e2e}/server-modal.spec.ts (73%) rename {playwright/tests => tests/e2e}/settings.spec.ts (64%) rename {playwright/tests => tests/e2e}/sidebar.spec.ts (70%) create mode 100644 tests/e2e/support/app-fixture.ts create mode 100644 tests/e2e/support/e2e-runtime.ts diff --git a/playwright.config.ts b/playwright.config.ts index 957c0531..15205f6e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import process from 'node:process'; export default defineConfig({ - testDir: './playwright/tests', + testDir: './tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, @@ -10,9 +10,12 @@ export default defineConfig({ reporter: process.env.CI ? 'github' : 'list', use: { - baseURL: 'http://localhost:5173', + baseURL: 'http://127.0.0.1:5173', trace: 'on-first-retry', screenshot: 'only-on-failure', + launchOptions: { + args: ['--force-prefers-reduced-motion'], + }, }, projects: [ @@ -23,8 +26,8 @@ export default defineConfig({ ], webServer: { - command: 'VITE_PLAYWRIGHT=true pnpm dev:plain', - url: 'http://localhost:5173', + command: 'HOST=127.0.0.1 VITE_PLAYWRIGHT=true pnpm dev:plain', + url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI, timeout: 120_000, stdout: 'pipe', diff --git a/playwright/tests/capture-theme-screenshots.spec.ts b/tests/e2e/capture-theme-screenshots.spec.ts similarity index 89% rename from playwright/tests/capture-theme-screenshots.spec.ts rename to tests/e2e/capture-theme-screenshots.spec.ts index 83383a4b..dcaad2fd 100644 --- a/playwright/tests/capture-theme-screenshots.spec.ts +++ b/tests/e2e/capture-theme-screenshots.spec.ts @@ -1,6 +1,6 @@ -import { expect, test } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; +import { expect, test } from './support/app-fixture'; /** * This spec does not assert theme correctness — it is a design-review utility @@ -34,9 +34,8 @@ test.describe('Liquid Glass screenshot capture', () => { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); }); - test('captures static (no-server) views', async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test('captures static (no-server) views', async ({ page, app }) => { + await app.gotoApp(); await page.locator('[data-testid="sidebar-brand-button"]').click(); await expect(page.locator('[data-testid="settings-window"]')).toBeVisible({ @@ -58,9 +57,8 @@ test.describe('Liquid Glass screenshot capture', () => { await capture(page, 'proxy-help'); }); - test('captures server-scoped views', async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test('captures server-scoped views', async ({ page, app }) => { + await app.gotoApp(); await page.locator('[data-testid="create-server-button"]').click(); await expect(page.locator('[data-testid="add-server-choice-modal"]')).toBeVisible({ diff --git a/playwright/tests/navigation.spec.ts b/tests/e2e/navigation.spec.ts similarity index 61% rename from playwright/tests/navigation.spec.ts rename to tests/e2e/navigation.spec.ts index e060fdeb..0daa1438 100644 --- a/playwright/tests/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -1,17 +1,14 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; const NAVIGABLE_VIEWS = ['dashboard', 'console', 'files', 'plugins', 'backups'] as const; test.describe('Navigation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); // ensure sidebar is open so nav items are visible & clickable const sidebar = page.locator('[data-testid="app-sidebar"]'); - const isOpen = await sidebar.evaluate((el) => - el.classList.contains('app-sidebar--open'), - ); + const isOpen = await sidebar.evaluate((el) => el.classList.contains('app-sidebar--open')); if (!isOpen) { await page.locator('[data-testid="sidebar-toggle-button"]').click(); await expect(sidebar).toHaveClass(/app-sidebar--open/); @@ -29,16 +26,10 @@ test.describe('Navigation', () => { test('main content area updates when switching views', async ({ page }) => { await page.locator('[data-testid="nav-item-dashboard"]').click(); - await expect( - page.locator('[data-testid="nav-item-dashboard"]'), - ).toHaveClass(/is-active/); + await expect(page.locator('[data-testid="nav-item-dashboard"]')).toHaveClass(/is-active/); await page.locator('[data-testid="nav-item-console"]').click(); - await expect( - page.locator('[data-testid="nav-item-console"]'), - ).toHaveClass(/is-active/); - await expect( - page.locator('[data-testid="nav-item-dashboard"]'), - ).toHaveClass(/is-idle/); + await expect(page.locator('[data-testid="nav-item-console"]')).toHaveClass(/is-active/); + await expect(page.locator('[data-testid="nav-item-dashboard"]')).toHaveClass(/is-idle/); }); }); diff --git a/playwright/tests/plugin-browser.spec.ts b/tests/e2e/plugin-browser.spec.ts similarity index 83% rename from playwright/tests/plugin-browser.spec.ts rename to tests/e2e/plugin-browser.spec.ts index d0666738..a744c5a6 100644 --- a/playwright/tests/plugin-browser.spec.ts +++ b/tests/e2e/plugin-browser.spec.ts @@ -1,9 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; test.describe('Plugin Browser UI', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); }); test('shows plugin browser when a server is selected and plugins nav is clicked', async ({ @@ -28,9 +27,7 @@ test.describe('Plugin Browser UI', () => { // Navigate to plugins view await page.locator('[data-testid="nav-item-plugins"]').click(); - await expect( - page.locator('[data-testid="plugin-browser"]'), - ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="plugin-browser"]')).toBeVisible({ timeout: 15_000 }); }); test('shows plugin search input in plugin browser', async ({ page }) => { @@ -51,8 +48,8 @@ test.describe('Plugin Browser UI', () => { await page.locator('[data-testid="nav-item-plugins"]').click(); - await expect( - page.locator('[data-testid="plugin-search-input"]'), - ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="plugin-search-input"]')).toBeVisible({ + timeout: 15_000, + }); }); }); diff --git a/playwright/tests/server-lifecycle.spec.ts b/tests/e2e/server-lifecycle.spec.ts similarity index 79% rename from playwright/tests/server-lifecycle.spec.ts rename to tests/e2e/server-lifecycle.spec.ts index b6e00ed4..94664a9d 100644 --- a/playwright/tests/server-lifecycle.spec.ts +++ b/tests/e2e/server-lifecycle.spec.ts @@ -1,18 +1,15 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; test.describe('Server Lifecycle', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); }); - test('creates a new server and shows it in the server list', async ({ - page, - }) => { + test('creates a new server and shows it in the server list', async ({ page }) => { await page.locator('[data-testid="create-server-button"]').click(); - await expect( - page.locator('[data-testid="add-server-choice-modal"]'), - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('[data-testid="add-server-choice-modal"]')).toBeVisible({ + timeout: 5_000, + }); await page.locator('[data-testid="choice-new-server-button"]').click(); const modal = page.locator('[data-testid="add-server-modal"]'); @@ -24,9 +21,9 @@ test.describe('Server Lifecycle', () => { await modal.locator('[data-testid="save-server-button"]').click(); await expect(modal).toHaveCount(0, { timeout: 15_000 }); - await expect( - page.locator('[data-testid^="server-card-"]').last(), - ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid^="server-card-"]').last()).toBeVisible({ + timeout: 15_000, + }); }); test('deletes a server via context menu', async ({ page }) => { @@ -55,9 +52,7 @@ test.describe('Server Lifecycle', () => { const serverCard = page.locator(`[data-testid="server-card-${serverId}"]`); await serverCard.click({ button: 'right' }); - await page - .locator(`[data-testid="delete-server-${serverId}"]`) - .click({ timeout: 5_000 }); + await page.locator(`[data-testid="delete-server-${serverId}"]`).click({ timeout: 5_000 }); await expect(serverCard).toHaveCount(0, { timeout: 10_000 }); }); diff --git a/playwright/tests/server-modal.spec.ts b/tests/e2e/server-modal.spec.ts similarity index 73% rename from playwright/tests/server-modal.spec.ts rename to tests/e2e/server-modal.spec.ts index 16586b98..2316977d 100644 --- a/playwright/tests/server-modal.spec.ts +++ b/tests/e2e/server-modal.spec.ts @@ -1,23 +1,20 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; test.describe('Server creation modal', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); }); test('create server button is visible in sidebar', async ({ page }) => { - await expect( - page.locator('[data-testid="create-server-button"]'), - ).toBeVisible(); + await expect(page.locator('[data-testid="create-server-button"]')).toBeVisible(); }); test('clicking create server opens choice modal', async ({ page }) => { await page.locator('[data-testid="create-server-button"]').click(); - await expect( - page.locator('[data-testid="add-server-choice-modal"]'), - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('[data-testid="add-server-choice-modal"]')).toBeVisible({ + timeout: 5_000, + }); }); test('choice modal has new server and import options', async ({ page }) => { @@ -27,18 +24,14 @@ test.describe('Server creation modal', () => { await expect(modal).toBeVisible({ timeout: 5_000 }); await expect(modal.locator('[data-testid="choice-new-server-button"]')).toBeVisible(); - await expect( - modal.locator('[data-testid="choice-import-server-button"]'), - ).toBeVisible(); + await expect(modal.locator('[data-testid="choice-import-server-button"]')).toBeVisible(); }); test('new server button opens add server form', async ({ page }) => { await page.locator('[data-testid="create-server-button"]').click(); await page.locator('[data-testid="choice-new-server-button"]').click(); - await expect( - page.locator('[data-testid="add-server-modal"]'), - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('[data-testid="add-server-modal"]')).toBeVisible({ timeout: 5_000 }); }); test('add server modal has required form fields', async ({ page }) => { diff --git a/playwright/tests/settings.spec.ts b/tests/e2e/settings.spec.ts similarity index 64% rename from playwright/tests/settings.spec.ts rename to tests/e2e/settings.spec.ts index bef8d3c8..48892975 100644 --- a/playwright/tests/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -1,9 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; test.describe('Settings', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); }); test('brand button opens settings view', async ({ page }) => { @@ -11,9 +10,7 @@ test.describe('Settings', () => { await expect(brandBtn).toBeVisible(); await brandBtn.click(); - await expect( - page.locator('[data-testid="settings-window"]'), - ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="settings-window"]')).toBeVisible({ timeout: 10_000 }); }); test('settings window contains language select', async ({ page }) => { @@ -27,15 +24,11 @@ test.describe('Settings', () => { test('can return from settings by navigating to another view', async ({ page }) => { await page.locator('[data-testid="sidebar-brand-button"]').click(); - await expect( - page.locator('[data-testid="settings-window"]'), - ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="settings-window"]')).toBeVisible({ timeout: 10_000 }); // Navigate away — settings window should disappear await page.locator('[data-testid="nav-item-dashboard"]').click(); - await expect( - page.locator('[data-testid="settings-window"]'), - ).toHaveCount(0); + await expect(page.locator('[data-testid="settings-window"]')).toHaveCount(0); }); }); diff --git a/playwright/tests/sidebar.spec.ts b/tests/e2e/sidebar.spec.ts similarity index 70% rename from playwright/tests/sidebar.spec.ts rename to tests/e2e/sidebar.spec.ts index e67c1343..923816f3 100644 --- a/playwright/tests/sidebar.spec.ts +++ b/tests/e2e/sidebar.spec.ts @@ -1,9 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './support/app-fixture'; test.describe('Sidebar', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + test.beforeEach(async ({ app }) => { + await app.gotoApp(); }); test('renders app shell with sidebar and main area', async ({ page }) => { @@ -21,18 +20,14 @@ test.describe('Sidebar', () => { }); test('sidebar toggle button is present', async ({ page }) => { - await expect( - page.locator('[data-testid="sidebar-toggle-button"]'), - ).toBeVisible(); + await expect(page.locator('[data-testid="sidebar-toggle-button"]')).toBeVisible(); }); test('sidebar collapses when toggle is clicked', async ({ page }) => { const sidebar = page.locator('[data-testid="app-sidebar"]'); const toggle = page.locator('[data-testid="sidebar-toggle-button"]'); - const isOpen = await sidebar.evaluate((el) => - el.classList.contains('app-sidebar--open'), - ); + const isOpen = await sidebar.evaluate((el) => el.classList.contains('app-sidebar--open')); await toggle.click(); @@ -44,8 +39,6 @@ test.describe('Sidebar', () => { }); test('sidebar brand button is present', async ({ page }) => { - await expect( - page.locator('[data-testid="sidebar-brand-button"]'), - ).toBeVisible(); + await expect(page.locator('[data-testid="sidebar-brand-button"]')).toBeVisible(); }); }); diff --git a/tests/e2e/support/app-fixture.ts b/tests/e2e/support/app-fixture.ts new file mode 100644 index 00000000..393c9e38 --- /dev/null +++ b/tests/e2e/support/app-fixture.ts @@ -0,0 +1,134 @@ +import { type Page, test as base, expect } from '@playwright/test'; +import { type E2eCall, type E2eScenario, type E2eState, createE2eState } from './e2e-runtime'; + +type E2eWindow = Window & { + __MC_VECTOR_E2E__?: { + version: 1; + scenario: E2eScenario; + calls: E2eCall[]; + state: E2eState; + }; +}; + +export interface AppFixture { + gotoApp(scenario?: E2eScenario): Promise; + createServer(options?: { + software?: 'Paper' | 'Fabric' | 'Forge'; + version?: string; + serverId?: string; + }): Promise; + selectServer(serverId: string): Promise; + openView(view: string): Promise; + ipcCalls(name?: string): Promise; + clearCalls(): Promise; + expectNoIpcCall(name: string): Promise; +} + +function runtimeStateForScenario(scenario: E2eScenario): E2eState { + return createE2eState(scenario); +} + +export function createAppFixture(page: Page): AppFixture { + const ensureSidebarOpen = async () => { + const sidebar = page.locator('[data-testid="app-sidebar"]'); + const className = await sidebar.getAttribute('class'); + if (!className?.includes('app-sidebar--open')) { + await page.locator('[data-testid="sidebar-toggle-button"]').click(); + await expect(sidebar).toHaveClass(/app-sidebar--open/); + } + }; + + return { + async gotoApp(scenario = 'default') { + const state = runtimeStateForScenario(scenario); + await page.addInitScript( + ({ scenario: initialScenario, state: initialState }) => { + (window as E2eWindow).__MC_VECTOR_E2E__ = { + version: 1, + scenario: initialScenario, + calls: [], + state: initialState, + }; + }, + { scenario, state }, + ); + await page.goto('/'); + await page.waitForSelector('[data-testid="app-root"]', { timeout: 15_000 }); + if (state.servers.length > 0) { + await expect( + page.locator(`[data-testid="server-card-${state.servers[0].id}"]`), + ).toBeVisible({ + timeout: 15_000, + }); + } + }, + + async createServer(options = {}) { + await ensureSidebarOpen(); + await page.locator('[data-testid="create-server-button"]').click(); + await page.locator('[data-testid="choice-new-server-button"]').click(); + const modal = page.locator('[data-testid="add-server-modal"]'); + await expect(modal).toBeVisible(); + const serverName = options.serverId ?? `e2e-${Date.now()}`; + await modal.locator('[data-testid="server-name-input"]').fill(serverName); + await modal.locator('[data-testid="server-port-input"]').fill('25565'); + await modal.locator('[data-testid="server-memory-input"]').fill('2'); + if (options.software) { + await modal + .locator('[data-testid="server-software-select"]') + .selectOption(options.software); + } + if (options.version) { + await modal.locator('[data-testid="server-version-select"]').selectOption(options.version); + } + await modal.locator('[data-testid="save-server-button"]').click(); + await expect(modal).toHaveCount(0, { timeout: 15_000 }); + const cards = page.locator('[data-testid^="server-card-"]'); + await expect(cards.last()).toBeVisible({ timeout: 15_000 }); + const testId = await cards.last().getAttribute('data-testid'); + const serverId = testId?.replace('server-card-', ''); + if (!serverId) throw new Error('The created server did not expose a stable id'); + return serverId; + }, + + async selectServer(serverId: string) { + await page.locator(`[data-testid="server-card-${serverId}"]`).click(); + }, + + async openView(view: string) { + await ensureSidebarOpen(); + if (view === 'app-settings') { + await page.locator('[data-testid="sidebar-brand-button"]').click(); + return; + } + await page.locator(`[data-testid="nav-item-${view}"]`).click(); + }, + + async ipcCalls(name?: string) { + return page.evaluate((filter) => { + const runtime = (window as E2eWindow).__MC_VECTOR_E2E__; + const calls = runtime?.calls ?? []; + return calls.filter((call) => call.kind === 'ipc' && (!filter || call.name === filter)); + }, name); + }, + + async clearCalls() { + await page.evaluate(() => { + const runtime = (window as E2eWindow).__MC_VECTOR_E2E__; + if (runtime) runtime.calls = []; + }); + }, + + async expectNoIpcCall(name: string) { + await expect.poll(async () => (await this.ipcCalls(name)).length).toBe(0); + }, + }; +} + +export const test = base.extend<{ app: AppFixture }>({ + app: async ({ page }, use) => { + await use(createAppFixture(page)); + }, +}); + +export { expect }; diff --git a/tests/e2e/support/e2e-runtime.ts b/tests/e2e/support/e2e-runtime.ts new file mode 100644 index 00000000..103f11c1 --- /dev/null +++ b/tests/e2e/support/e2e-runtime.ts @@ -0,0 +1,303 @@ +import type { + MinecraftServer, + ServerStatus, +} from '../../../src/renderer/shared/server declaration'; + +export type E2eScenario = + | 'default' + | 'paper-plugin-success' + | 'fabric-mod-success' + | 'incompatible-plugin' + | 'hashless-plugin' + | 'checksum-mismatch' + | 'checksum-invalid' + | 'network-retry' + | 'search-failure' + | 'overwrite-existing-plugin' + | 'disabled-plugin' + | 'backup-flow' + | 'file-flow' + | 'proxy-flow'; + +export type E2eCallKind = 'ipc' | 'http' | 'store' | 'fs' | 'dialog' | 'opener'; + +export type E2eCall = { + kind: E2eCallKind; + name: string; + args?: unknown; +}; + +export type E2eFileNode = { + kind: 'file' | 'directory'; + content?: string; + modified?: number; +}; + +export type E2eState = { + servers: MinecraftServer[]; + files: Record; + config: Record; + javaVersions: unknown[]; + backups: Record; + runningServerIds: string[]; + pluginDownloadAttempts: number; + searchAttempts: number; + ngrokStatus: 'offline' | 'online'; +}; + +export type E2eRuntime = { + version: 1; + scenario: E2eScenario; + calls: E2eCall[]; + state: E2eState; +}; + +export const E2E_RUNTIME_KEY = '__MC_VECTOR_E2E__'; + +type E2eWindow = Window & { + [E2E_RUNTIME_KEY]?: E2eRuntime; +}; + +const DEFAULT_APP_DATA_DIR = '/mock/app-data'; + +export function serverPath(serverId: string): string { + return `${DEFAULT_APP_DATA_DIR}/servers/${serverId}`; +} + +export function createServerFixture( + serverId: string, + software = 'Paper', + version = '1.21.10', +): MinecraftServer { + return { + id: serverId, + name: serverId === 'server-1' ? 'E2E Server' : serverId, + version, + software, + port: 25565, + memory: 2048, + path: serverPath(serverId), + status: 'offline', + createdDate: '2026-01-01T00:00:00.000Z', + }; +} + +function baseFiles(serverId: string, software: string): Record { + const root = serverPath(serverId); + const pluginDir = software === 'Fabric' || software === 'Forge' ? 'mods' : 'plugins'; + const files: Record = { + [root]: { kind: 'directory' }, + [`${root}/server.properties`]: { + kind: 'file', + content: 'motd=E2E Server\ngamemode=survival\nserver-port=25565\n', + }, + [`${root}/whitelist.json`]: { kind: 'file', content: '[]' }, + [`${root}/ops.json`]: { kind: 'file', content: '[]' }, + [`${root}/banned-players.json`]: { kind: 'file', content: '[]' }, + [`${root}/banned-ips.json`]: { kind: 'file', content: '[]' }, + [`${root}/${pluginDir}`]: { kind: 'directory' }, + [`${root}/world`]: { kind: 'directory' }, + [`${root}/backups`]: { kind: 'directory' }, + [`${root}/server.jar`]: { kind: 'file', content: 'e2e-server-jar' }, + }; + + return files; +} + +export function createE2eState(scenario: E2eScenario): E2eState { + const seededServerScenario = new Set([ + 'paper-plugin-success', + 'fabric-mod-success', + 'incompatible-plugin', + 'hashless-plugin', + 'checksum-mismatch', + 'checksum-invalid', + 'network-retry', + 'search-failure', + 'overwrite-existing-plugin', + 'disabled-plugin', + 'backup-flow', + 'file-flow', + 'proxy-flow', + ]).has(scenario); + const software = scenario === 'fabric-mod-success' ? 'Fabric' : 'Paper'; + const server = createServerFixture('server-1', software); + const files = seededServerScenario ? baseFiles(server.id, software) : {}; + const pluginDir = software === 'Fabric' ? 'mods' : 'plugins'; + + if (scenario === 'overwrite-existing-plugin' || scenario === 'disabled-plugin') { + files[`${server.path}/${pluginDir}/VeinMiner-1.21.4.jar`] = { + kind: 'file', + content: 'old-plugin-jar', + }; + } + if (scenario === 'disabled-plugin') { + delete files[`${server.path}/${pluginDir}/VeinMiner-1.21.4.jar`]; + files[`${server.path}/${pluginDir}/VeinMiner-1.21.4.jar.disabled`] = { + kind: 'file', + content: 'disabled-plugin-jar', + }; + } + + return { + servers: seededServerScenario ? [server] : [], + files, + config: {}, + javaVersions: [], + backups: {}, + runningServerIds: [], + pluginDownloadAttempts: 0, + searchAttempts: 0, + ngrokStatus: 'offline', + }; +} + +export function getE2eRuntime(): E2eRuntime | null { + if (typeof window === 'undefined') { + return null; + } + return (window as E2eWindow)[E2E_RUNTIME_KEY] ?? null; +} + +export function requireE2eRuntime(): E2eRuntime { + const runtime = getE2eRuntime(); + if (!runtime || runtime.version !== 1) { + throw new Error('E2E runtime was not initialized before the app loaded'); + } + return runtime; +} + +export function recordE2eCall(kind: E2eCallKind, name: string, args?: unknown): void { + requireE2eRuntime().calls.push({ kind, name, args }); +} + +export function getE2eScenario(): E2eScenario { + return requireE2eRuntime().scenario; +} + +export function getE2eState(): E2eState { + return requireE2eRuntime().state; +} + +export function setServerStatus(serverId: string, status: ServerStatus): void { + const state = getE2eState(); + state.servers = state.servers.map((server) => + server.id === serverId ? { ...server, status } : server, + ); + if (status === 'online' || status === 'starting' || status === 'restarting') { + if (!state.runningServerIds.includes(serverId)) state.runningServerIds.push(serverId); + } else { + state.runningServerIds = state.runningServerIds.filter((id) => id !== serverId); + } +} + +export function resolveRuntimePath(request: { + root: string; + serverId?: string; + relativePath: string; +}): string { + const serverSegment = request.serverId ? `/${request.serverId}` : ''; + const relativePath = request.relativePath ? `/${request.relativePath}` : ''; + return `${DEFAULT_APP_DATA_DIR}/${request.root}${serverSegment}${relativePath}`; +} + +export function ensureRuntimeDirectory(path: string): void { + const state = getE2eState(); + if (!state.files[path]) { + state.files[path] = { kind: 'directory' }; + } +} + +export function setRuntimeFile(path: string, content: string): void { + const state = getE2eState(); + const parent = path.slice(0, path.lastIndexOf('/')); + if (parent) ensureRuntimeDirectory(parent); + state.files[path] = { kind: 'file', content, modified: Date.now() }; +} + +export function removeRuntimePath(path: string): void { + const state = getE2eState(); + for (const key of Object.keys(state.files)) { + if (key === path || key.startsWith(`${path}/`)) { + delete state.files[key]; + } + } +} + +export function moveRuntimePath(from: string, to: string): void { + const state = getE2eState(); + const entries = Object.entries(state.files).filter( + ([key]) => key === from || key.startsWith(`${from}/`), + ); + if (entries.length === 0) { + throw new Error(`Path not found: ${from}`); + } + if (state.files[to]) { + throw new Error(`Destination already exists: ${to}`); + } + removeRuntimePath(from); + const parent = to.slice(0, to.lastIndexOf('/')); + if (parent) ensureRuntimeDirectory(parent); + for (const [key, node] of entries) { + const suffix = key.slice(from.length); + state.files[`${to}${suffix}`] = node; + } +} + +export function listRuntimeChildren(path: string): Array<{ + name: string; + isDirectory: boolean; + size: number; + modified: number; +}> { + const state = getE2eState(); + const prefix = path.endsWith('/') ? path : `${path}/`; + const children = new Map(); + for (const [key, node] of Object.entries(state.files)) { + if (!key.startsWith(prefix) || key === path) continue; + const remainder = key.slice(prefix.length); + const name = remainder.split('/')[0]; + if (!name) continue; + const isDirectFile = !remainder.includes('/'); + const previous = children.get(name); + if (previous) { + previous.isDirectory = true; + continue; + } + children.set(name, { + isDirectory: node.kind === 'directory' || !isDirectFile, + size: + node.kind === 'file' && isDirectFile + ? new TextEncoder().encode(node.content ?? '').length + : 0, + modified: Math.floor((node.modified ?? Date.now()) / 1000), + }); + } + return [...children.entries()] + .map(([name, metadata]) => ({ name, ...metadata })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function readRuntimeFile(path: string): string { + const node = getE2eState().files[path]; + if (node?.kind === 'file') return node.content ?? ''; + if ( + path.endsWith('/whitelist.json') || + path.endsWith('/ops.json') || + path.endsWith('/banned-players.json') || + path.endsWith('/banned-ips.json') + ) + return '[]'; + if (path.endsWith('/server.properties')) + return 'motd=E2E Server\ngamemode=survival\nserver-port=25565\n'; + throw new Error(`Path not found: ${path}`); +} + +export function seedRuntimeOnWindow(windowValue: Window, scenario: E2eScenario): void { + (windowValue as E2eWindow)[E2E_RUNTIME_KEY] = { + version: 1, + scenario, + calls: [], + state: createE2eState(scenario), + }; +} diff --git a/tests/mocks/tauri-api-core.ts b/tests/mocks/tauri-api-core.ts index 62132cab..8ba6a464 100644 --- a/tests/mocks/tauri-api-core.ts +++ b/tests/mocks/tauri-api-core.ts @@ -1,19 +1,280 @@ -const mockData: Record = { - get_servers: [], - is_server_running: false, - get_server_pid: null, - get_server_stats: null, - ping_server: false, - get_app_location: '/mock/app', - can_update_app: false, - parse_ansi_lines: [], - get_server_templates: [], -}; - -export async function invoke(cmd: string, _args?: unknown): Promise { - if (cmd in mockData) return mockData[cmd] as T; - console.warn(`[tauri-mock] invoke: ${cmd}`); - return null as T; +import { + ensureRuntimeDirectory, + getE2eScenario, + getE2eState, + listRuntimeChildren, + moveRuntimePath, + readRuntimeFile, + recordE2eCall, + removeRuntimePath, + resolveRuntimePath, + setRuntimeFile, +} from '../e2e/support/e2e-runtime'; +import { emit } from './tauri-api-event'; + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function requestPath(value: unknown): string { + const request = asRecord(value); + return resolveRuntimePath({ + root: String(request.root ?? ''), + serverId: typeof request.serverId === 'string' ? request.serverId : undefined, + relativePath: String(request.relativePath ?? ''), + }); +} + +function errorWithCode(code: string): Error { + return new Error(JSON.stringify({ code })); +} + +function emitStatus(serverId: string, status: string): void { + void emit('server-status-change', { serverId, status }); +} + +function emitProgress(serverId: string, status: string): void { + void emit('download-progress', { serverId, progress: 100, status }); +} + +function pluginDestinationPath(request: Record): string { + const serverId = typeof request.serverId === 'string' ? request.serverId : ''; + const relativePath = typeof request.relativePath === 'string' ? request.relativePath : ''; + const isManagedPluginPath = /^(?:plugins|mods)\/[^/\\]+\.jar$/i.test(relativePath); + if (!serverId || !isManagedPluginPath) { + throw errorWithCode('destination-rejected'); + } + return resolveRuntimePath({ root: 'servers', serverId, relativePath }); +} + +async function invokeCommand(cmd: string, args: unknown): Promise { + const state = getE2eState(); + const payload = asRecord(args); + + switch (cmd) { + case 'resolve_managed_path': + return requestPath(payload.request); + + case 'create_managed_directory': + ensureRuntimeDirectory(requestPath(payload.request)); + return null; + + case 'list_dir_with_metadata': + return listRuntimeChildren(requestPath(payload.request)); + + case 'read_managed_text_file': + return readRuntimeFile(requestPath(payload.request)); + + case 'write_managed_text_file': + setRuntimeFile(requestPath(payload.request), String(payload.content ?? '')); + return null; + + case 'delete_managed_path': + removeRuntimePath(requestPath(payload.request)); + return null; + + case 'move_managed_path': + moveRuntimePath(requestPath(payload.from), requestPath(payload.to)); + return null; + + case 'delete_managed_server_dir': + removeRuntimePath( + resolveRuntimePath({ + root: 'servers', + serverId: String(payload.serverId), + relativePath: '', + }), + ); + return null; + + case 'clone_managed_server': { + const sourceId = String(payload.sourceServerId ?? ''); + const destinationId = String(payload.destinationServerId ?? ''); + const sourceRoot = resolveRuntimePath({ + root: 'servers', + serverId: sourceId, + relativePath: '', + }); + const destinationRoot = resolveRuntimePath({ + root: 'servers', + serverId: destinationId, + relativePath: '', + }); + const sourceEntries = Object.entries(state.files).filter( + ([path]) => path === sourceRoot || path.startsWith(`${sourceRoot}/`), + ); + ensureRuntimeDirectory(destinationRoot); + for (const [path, node] of sourceEntries) { + const destinationPath = `${destinationRoot}${path.slice(sourceRoot.length)}`; + if (node.kind === 'directory') ensureRuntimeDirectory(destinationPath); + else setRuntimeFile(destinationPath, node.content ?? ''); + } + return null; + } + + case 'import_managed_files': { + const serverId = String(payload.serverId ?? 'server-1'); + const relativePath = 'imported.jar'; + setRuntimeFile( + resolveRuntimePath({ root: 'servers', serverId, relativePath }), + 'imported-e2e-file', + ); + return [{ serverId, relativePath, isDirectory: false, size: 17 }]; + } + + case 'compress_managed_items': + setRuntimeFile(requestPath(payload.destination), 'e2e-archive'); + return requestPath(payload.destination); + + case 'extract_managed_item': + ensureRuntimeDirectory(requestPath(payload.destination)); + return null; + + case 'migrate_managed_server_directory': { + const serverId = String(payload.serverId ?? ''); + ensureRuntimeDirectory(resolveRuntimePath({ root: 'servers', serverId, relativePath: '' })); + return resolveRuntimePath({ root: 'servers', serverId, relativePath: '' }); + } + + case 'download_server_jar': { + const request = asRecord(payload.request); + const serverId = String(request.serverId ?? ''); + const relativePath = String(request.relativePath ?? 'server.jar'); + setRuntimeFile( + resolveRuntimePath({ root: 'servers', serverId, relativePath }), + 'e2e-server-jar', + ); + emitProgress(serverId, 'server.jar'); + return null; + } + + case 'download_plugin_artifact': { + const request = asRecord(payload.request); + const destination = pluginDestinationPath(request); + state.pluginDownloadAttempts += 1; + const checksum = request.checksum; + const scenario = getE2eScenario(); + if ( + scenario === 'hashless-plugin' || + (!checksum && state.config.allowUnverifiedPluginDownloads !== true) + ) { + throw errorWithCode('unverified-artifact-blocked'); + } + if (scenario === 'checksum-mismatch') throw errorWithCode('checksum-mismatch'); + if (scenario === 'checksum-invalid') throw errorWithCode('checksum-invalid'); + if (scenario === 'network-retry' && state.pluginDownloadAttempts === 1) { + throw errorWithCode('network'); + } + setRuntimeFile(destination, 'e2e-plugin-jar'); + emitProgress(String(request.serverId), 'plugin'); + return null; + } + + case 'start_server': { + const serverId = String(payload.serverId ?? ''); + state.runningServerIds = [...new Set([...state.runningServerIds, serverId])]; + emitStatus(serverId, 'starting'); + emitStatus(serverId, 'online'); + return null; + } + + case 'stop_server': { + const serverId = String(payload.serverId ?? ''); + state.runningServerIds = state.runningServerIds.filter((id) => id !== serverId); + emitStatus(serverId, 'stopping'); + emitStatus(serverId, 'offline'); + return null; + } + + case 'send_command': + void emit('server-log', { + serverId: payload.serverId, + line: `> ${payload.command}`, + stream: 'stdout', + }); + return null; + + case 'is_server_running': + return state.runningServerIds.includes(String(payload.serverId ?? '')); + + case 'get_server_pid': + return state.runningServerIds.includes(String(payload.serverId ?? '')) ? 4321 : null; + + case 'get_server_stats': + return { cpu: 1, memory: 256 }; + + case 'ping_server': + return true; + + case 'parse_ansi_lines': + return []; + + case 'create_managed_backup': { + const serverId = String(payload.serverId ?? ''); + const backupName = String(payload.backupName ?? 'backup.zip'); + const backupPath = resolveRuntimePath({ + root: 'backups', + serverId, + relativePath: backupName, + }); + setRuntimeFile(backupPath, 'e2e-backup'); + state.backups[serverId] = [...(state.backups[serverId] ?? []), backupName]; + void emit('backup-progress', { serverId, progress: 100 }); + return null; + } + + case 'restore_managed_backup': + return null; + + case 'download_java': { + const majorVersion = Number(payload.majorVersion ?? 17); + ensureRuntimeDirectory( + resolveRuntimePath({ root: 'java', relativePath: `jdk-${majorVersion}` }), + ); + void emit('java-download-progress', { progress: 100 }); + return resolveRuntimePath({ root: 'java', relativePath: `jdk-${majorVersion}` }); + } + + case 'start_ngrok': + state.ngrokStatus = 'online'; + void emit('ngrok-status-change', { + status: 'online', + url: 'https://example.ngrok.test', + serverId: payload.serverId, + }); + return null; + + case 'stop_ngrok': + state.ngrokStatus = 'offline'; + void emit('ngrok-status-change', { status: 'offline', serverId: payload.serverId }); + return null; + + case 'download_ngrok': + ensureRuntimeDirectory(resolveRuntimePath({ root: 'ngrok', relativePath: '' })); + return resolveRuntimePath({ root: 'ngrok', relativePath: 'ngrok' }); + + case 'is_ngrok_installed': + return true; + + case 'can_update_app': + return false; + + case 'get_app_location': + return '/mock/app'; + + case 'pick_server_import': + case 'complete_server_import': + case 'cancel_server_import': + return null; + + default: + throw new Error(`Unregistered E2E IPC command: ${cmd}`); + } +} + +export async function invoke(cmd: string, args?: unknown): Promise { + recordE2eCall('ipc', cmd, args); + return (await invokeCommand(cmd, args)) as T; } export function isTauri(): boolean { diff --git a/tests/mocks/tauri-api-event.ts b/tests/mocks/tauri-api-event.ts index e9e626da..bf9e213a 100644 --- a/tests/mocks/tauri-api-event.ts +++ b/tests/mocks/tauri-api-event.ts @@ -1,3 +1,5 @@ +import { recordE2eCall } from '../e2e/support/e2e-runtime'; + export type UnlistenFn = () => void; export interface Event { @@ -7,20 +9,48 @@ export interface Event { windowLabel: string; } +type Listener = (event: Event) => void; +const listeners = new Map>(); + export async function listen( - _event: string, - _handler: (event: Event) => void, + event: string, + handler: (event: Event) => void, ): Promise { - return () => {}; + recordE2eCall('ipc', 'listen', { event }); + const listener = handler as (event: Event) => void; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + return () => { + eventListeners.delete(listener); + }; } export async function once( - _event: string, - _handler: (event: Event) => void, + event: string, + handler: (event: Event) => void, ): Promise { - return () => {}; + const unlisten = await listen(event, (payload) => { + unlisten(); + handler(payload as Event); + }); + return unlisten; } -export async function emit(_event: string, _payload?: unknown): Promise {} +export async function emit(event: string, payload?: unknown): Promise { + recordE2eCall('ipc', 'emit', { event, payload }); + const eventListeners = listeners.get(event); + if (!eventListeners) return; + const eventPayload: Event = { + event, + payload, + id: 1, + windowLabel: 'main', + }; + for (const listener of eventListeners) listener(eventPayload); +} -export async function emitTo(_target: string, _event: string, _payload?: unknown): Promise {} +export async function emitTo(target: string, event: string, payload?: unknown): Promise { + recordE2eCall('ipc', 'emitTo', { target, event, payload }); + await emit(event, payload); +} diff --git a/tests/mocks/tauri-plugin-dialog.ts b/tests/mocks/tauri-plugin-dialog.ts index 24a0cb86..f47f684f 100644 --- a/tests/mocks/tauri-plugin-dialog.ts +++ b/tests/mocks/tauri-plugin-dialog.ts @@ -1,17 +1,32 @@ -export async function open(_options?: unknown): Promise { - return null; +import { getE2eState, recordE2eCall } from '../e2e/support/e2e-runtime'; + +type E2eWindow = Window & { + __dialogConfirm?: boolean; + __dialogSelectedPath?: string | string[] | null; +}; + +export async function open(options?: unknown): Promise { + recordE2eCall('dialog', 'open', options); + const value = (window as E2eWindow).__dialogSelectedPath; + if (value !== undefined) return value; + return `/mock/external/e2e-import-${getE2eState().servers[0]?.id ?? 'server-1'}`; } -export async function save(_options?: unknown): Promise { - return null; +export async function save(options?: unknown): Promise { + recordE2eCall('dialog', 'save', options); + return '/mock/external/e2e-export.txt'; } -export async function ask(_message: string, _options?: unknown): Promise { - return (window as unknown as Record).__dialogConfirm === true; +export async function ask(message: string, options?: unknown): Promise { + recordE2eCall('dialog', 'ask', { message, options }); + return (window as E2eWindow).__dialogConfirm === true; } -export async function confirm(_message: string, _options?: unknown): Promise { - return (window as unknown as Record).__dialogConfirm === true; +export async function confirm(message: string, options?: unknown): Promise { + recordE2eCall('dialog', 'confirm', { message, options }); + return (window as E2eWindow).__dialogConfirm === true; } -export async function message(_message: string, _options?: unknown): Promise {} +export async function message(messageText: string, options?: unknown): Promise { + recordE2eCall('dialog', 'message', { message: messageText, options }); +} diff --git a/tests/mocks/tauri-plugin-fs.ts b/tests/mocks/tauri-plugin-fs.ts index c0c415cf..f38f2f75 100644 --- a/tests/mocks/tauri-plugin-fs.ts +++ b/tests/mocks/tauri-plugin-fs.ts @@ -1,3 +1,14 @@ +import { + ensureRuntimeDirectory, + getE2eState, + listRuntimeChildren, + moveRuntimePath, + readRuntimeFile, + recordE2eCall, + removeRuntimePath, + setRuntimeFile, +} from '../e2e/support/e2e-runtime'; + export interface DirEntry { name: string; isFile: boolean; @@ -5,40 +16,66 @@ export interface DirEntry { isSymlink: boolean; } -export async function readDir(_path: string, _options?: unknown): Promise { - return []; +export async function readDir(path: string, _options?: unknown): Promise { + recordE2eCall('fs', 'readDir', { path }); + return listRuntimeChildren(path).map((entry) => ({ + name: entry.name, + isFile: !entry.isDirectory, + isDirectory: entry.isDirectory, + isSymlink: false, + })); } -export async function mkdir(_path: string, _options?: unknown): Promise {} +export async function mkdir(path: string, _options?: unknown): Promise { + recordE2eCall('fs', 'mkdir', { path }); + ensureRuntimeDirectory(path); +} -export async function remove(_path: string, _options?: unknown): Promise {} +export async function remove(path: string, _options?: unknown): Promise { + recordE2eCall('fs', 'remove', { path }); + removeRuntimePath(path); +} export async function copyFile( - _source: string, - _destination: string, + source: string, + destination: string, _options?: unknown, -): Promise {} +): Promise { + recordE2eCall('fs', 'copyFile', { source, destination }); + setRuntimeFile(destination, readRuntimeFile(source)); +} -export async function rename( - _oldPath: string, - _newPath: string, - _options?: unknown, -): Promise {} +export async function rename(oldPath: string, newPath: string, _options?: unknown): Promise { + recordE2eCall('fs', 'rename', { oldPath, newPath }); + moveRuntimePath(oldPath, newPath); +} export async function writeTextFile( - _path: string, - _contents: string, + path: string, + contents: string, _options?: unknown, -): Promise {} +): Promise { + recordE2eCall('fs', 'writeTextFile', { path, contents }); + setRuntimeFile(path, contents); +} -export async function readTextFile(_path: string, _options?: unknown): Promise { - return ''; +export async function readTextFile(path: string, _options?: unknown): Promise { + recordE2eCall('fs', 'readTextFile', { path }); + return readRuntimeFile(path); } -export async function exists(_path: string, _options?: unknown): Promise { - return false; +export async function exists(path: string, _options?: unknown): Promise { + recordE2eCall('fs', 'exists', { path }); + return Boolean(getE2eState().files[path]); } -export async function stat(_path: string, _options?: unknown) { - return { size: 0, mtime: new Date(), isFile: true, isDirectory: false }; +export async function stat(path: string, _options?: unknown) { + recordE2eCall('fs', 'stat', { path }); + const node = getE2eState().files[path]; + return { + size: node?.kind === 'file' ? new TextEncoder().encode(node.content ?? '').length : 0, + mtime: new Date(node?.modified ?? Date.now()), + isFile: node?.kind === 'file', + isDirectory: node?.kind === 'directory', + }; } diff --git a/tests/mocks/tauri-plugin-http.ts b/tests/mocks/tauri-plugin-http.ts index aa0d119c..35f3b0f2 100644 --- a/tests/mocks/tauri-plugin-http.ts +++ b/tests/mocks/tauri-plugin-http.ts @@ -1,33 +1,161 @@ +import { getE2eScenario, getE2eState, recordE2eCall } from '../e2e/support/e2e-runtime'; + const PAPER_TEST_JAR_URL = 'https://fill-data.papermc.io/v1/paper.jar'; +const VALID_SHA256 = 'a'.repeat(64); +const VALID_SHA512 = 'a'.repeat(128); + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} function paperBuildResponse(): Response { - return new Response( - JSON.stringify([ + return jsonResponse([ + { + channel: 'STABLE', + downloads: { + 'server:default': { + name: 'paper.jar', + url: PAPER_TEST_JAR_URL, + checksums: { sha256: '0'.repeat(64) }, + }, + }, + }, + ]); +} + +function modrinthProject(): Record { + const incompatible = getE2eScenario() === 'incompatible-plugin'; + return { + slug: 'veinminer', + project_id: 'veinminer-project', + title: 'VeinMiner', + description: incompatible + ? 'A plugin for Minecraft 1.20.1 only.' + : 'A plugin for Minecraft 1.21.10.', + author: 'Example', + icon_url: '', + downloads: 100, + project_type: 'plugin', + }; +} + +function modrinthVersion(): Record { + const hashless = getE2eScenario() === 'hashless-plugin'; + const incompatible = getE2eScenario() === 'incompatible-plugin'; + return { + id: 'version-1', + game_versions: [incompatible ? '1.20.1' : '1.21.10'], + dependencies: [], + files: [ { - channel: 'STABLE', - downloads: { - 'server:default': { - name: 'paper.jar', - url: PAPER_TEST_JAR_URL, - checksums: { sha256: '0'.repeat(64) }, - }, + filename: 'VeinMiner-1.21.4.jar', + url: 'https://cdn.modrinth.example/veinminer.jar', + primary: true, + hashes: hashless ? undefined : { sha512: VALID_SHA512 }, + }, + ], + }; +} + +function hangarProject(): Record { + return { + name: 'WirelessRedstone', + namespace: { owner: 'Example', slug: 'wireless-redstone' }, + stats: { downloads: 100, stars: 4 }, + description: 'A Paper plugin for Minecraft 1.21.10.', + avatarUrl: '', + }; +} + +function hangarVersion(): Record { + const hashless = getE2eScenario() === 'hashless-plugin'; + return { + name: '2.0', + downloads: { + PAPER: { + downloadUrl: 'https://cdn.hangar.example/wireless-redstone.jar', + fileInfo: { + name: 'WirelessRedstone-2.0.jar', + ...(hashless ? {} : { sha256: VALID_SHA256 }), }, }, - ]), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, }, - ); + platformDependencies: { PAPER: ['1.21.10'] }, + }; +} + +function spigotResource(): Record { + return { + id: 1, + name: 'VeinMiner', + tag: 'Supports Minecraft 1.21.10', + downloads: 100, + premium: false, + external: false, + icon: { url: '' }, + author: { name: 'Example' }, + file: { url: 'https://api.spiget.org/v2/resources/1/download?version=1', type: 'JAR' }, + }; } export async function fetch(url: string, _options?: unknown): Promise { + recordE2eCall('http', 'fetch', { url }); + const state = getE2eState(); + if (url.includes('fill.papermc.io/v3/projects/paper/versions/') && url.endsWith('/builds')) { return paperBuildResponse(); } - return new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + if (url.includes('api.modrinth.com/v2/search')) { + state.searchAttempts += 1; + if (getE2eScenario() === 'search-failure' && state.searchAttempts <= 2) { + throw new Error('E2E search network failure'); + } + return jsonResponse({ hits: [modrinthProject()], total_hits: 1 }); + } + + if (url.includes('api.modrinth.com/v2/project/veinminer-project/version')) { + return jsonResponse([modrinthVersion()]); + } + + if (url.includes('api.modrinth.com/v2/version/version-1')) { + return jsonResponse(modrinthVersion()); + } + + if (url.includes('api.modrinth.com/v2/project/veinminer-project')) { + return jsonResponse({ + id: 'veinminer-project', + slug: 'veinminer', + title: 'VeinMiner', + body: '# VeinMiner', + }); + } + + if (url.includes('hangar.papermc.io/api/v1/projects') && url.includes('/versions')) { + return jsonResponse({ result: [hangarVersion()], pagination: {} }); + } + + if (url === 'https://hangar.papermc.io/api/v1/projects' || url.includes('api/v1/projects?')) { + return jsonResponse({ result: [hangarProject()], pagination: {} }); + } + + if (url.includes('hangar.papermc.io/api/v1/projects/Example/wireless-redstone')) { + return jsonResponse({ description: 'A Paper plugin.', mainPageContent: '# WirelessRedstone' }); + } + + if ( + url.includes('api.spiget.org/v2/search/resources') || + url.includes('api.spiget.org/v2/resources/free') + ) { + return jsonResponse([spigotResource()]); + } + + if (url.includes('api.spiget.org/v2/resources/1')) { + return jsonResponse({ description: '

VeinMiner description

' }); + } + + return jsonResponse({}); } diff --git a/tests/mocks/tauri-plugin-opener.ts b/tests/mocks/tauri-plugin-opener.ts index 29f69eea..9890efb4 100644 --- a/tests/mocks/tauri-plugin-opener.ts +++ b/tests/mocks/tauri-plugin-opener.ts @@ -1,5 +1,18 @@ -export async function openUrl(_url: string): Promise {} +import { recordE2eCall } from '../e2e/support/e2e-runtime'; -export async function openPath(_path: string): Promise {} +type E2eWindow = Window & { __openerFail?: boolean }; -export async function revealItemInDir(_path: string): Promise {} +export async function openUrl(url: string): Promise { + recordE2eCall('opener', 'openUrl', { url }); + if ((window as E2eWindow).__openerFail === true) { + throw new Error('E2E opener failure'); + } +} + +export async function openPath(path: string): Promise { + recordE2eCall('opener', 'openPath', { path }); +} + +export async function revealItemInDir(path: string): Promise { + recordE2eCall('opener', 'revealItemInDir', { path }); +} diff --git a/tests/mocks/tauri-plugin-store.ts b/tests/mocks/tauri-plugin-store.ts index 80dd1d2d..914d3cdd 100644 --- a/tests/mocks/tauri-plugin-store.ts +++ b/tests/mocks/tauri-plugin-store.ts @@ -1,34 +1,145 @@ -const storage = new Map(); +import { getE2eState, recordE2eCall } from '../e2e/support/e2e-runtime'; -function createMockStore() { +type KeyListener = (value: unknown) => void; +type ChangeListener = (key: string, value: unknown) => void; + +const stores = new Map>(); +const keyListeners = new Map>>(); +const changeListeners = new Map>(); + +function storeData(path: string): Map { + const existing = stores.get(path); + if (existing) return existing; + const created = new Map(); + stores.set(path, created); + return created; +} + +function cloneValue(value: T): T { + if (value === undefined) return value; + return structuredClone(value); +} + +function initialValue(path: string, key: string): unknown { + const state = getE2eState(); + if (path.endsWith('servers.json')) { + if (key === 'servers') return state.servers; + if (key === 'serverTemplates') return []; + } + if (path.endsWith('config.json')) { + return state.config[key]; + } + if (path.endsWith('java.json') && key === 'javaVersions') return state.javaVersions; + return undefined; +} + +function updateRuntimeState(path: string, key: string, value: unknown): void { + const state = getE2eState(); + if (path.endsWith('servers.json') && key === 'servers' && Array.isArray(value)) { + state.servers = cloneValue(value); + } + if (path.endsWith('config.json')) { + state.config[key] = value; + } + if (path.endsWith('config.json') && key === 'javaVersions' && Array.isArray(value)) { + state.javaVersions = cloneValue(value); + } +} + +function notify(path: string, key: string, value: unknown): void { + keyListeners + .get(path) + ?.get(key) + ?.forEach((listener) => listener(value)); + changeListeners.get(path)?.forEach((listener) => listener(key, value)); +} + +function createMockStore(path: string) { + const data = storeData(path); return { - get: async (key: string): Promise => (storage.get(key) as T) ?? null, + get: async (key: string): Promise => { + recordE2eCall('store', 'get', { path, key }); + const value = data.has(key) ? data.get(key) : initialValue(path, key); + return value === undefined ? null : cloneValue(value as T); + }, set: async (key: string, value: unknown): Promise => { - storage.set(key, value); - }, - save: async (): Promise => {}, - delete: async (key: string): Promise => storage.delete(key), - entries: async (): Promise<[string, T][]> => [...storage.entries()] as [string, T][], - keys: async (): Promise => [...storage.keys()], - values: async (): Promise => [...storage.values()] as T[], - length: async (): Promise => storage.size, - clear: async (): Promise => storage.clear(), - has: async (key: string): Promise => storage.has(key), - onKeyChange: - (_key: string, _cb: (value: T | null) => void): (() => void) => - () => {}, - onChange: - (_cb: (key: string, value: T | null) => void): (() => void) => - () => {}, - reset: async (): Promise => {}, - close: async (): Promise => {}, + recordE2eCall('store', 'set', { path, key, value }); + data.set(key, value); + updateRuntimeState(path, key, value); + notify(path, key, value); + }, + save: async (): Promise => { + recordE2eCall('store', 'save', { path }); + if (getE2eState().config.__storeSaveError === true) { + throw new Error('E2E store save failed'); + } + }, + delete: async (key: string): Promise => { + recordE2eCall('store', 'delete', { path, key }); + const deleted = data.delete(key); + if (path.endsWith('config.json')) delete getE2eState().config[key]; + notify(path, key, null); + return deleted; + }, + entries: async (): Promise<[string, T][]> => { + recordE2eCall('store', 'entries', { path }); + const entries = new Map(data); + if (path.endsWith('servers.json') && !entries.has('servers')) + entries.set('servers', getE2eState().servers); + if (path.endsWith('config.json')) { + for (const [key, value] of Object.entries(getE2eState().config)) entries.set(key, value); + } + return [...entries.entries()] as [string, T][]; + }, + keys: async (): Promise => { + recordE2eCall('store', 'keys', { path }); + return (await createMockStore(path).entries()).map(([key]) => key); + }, + values: async (): Promise => { + recordE2eCall('store', 'values', { path }); + return (await createMockStore(path).entries()).map(([, value]) => value) as T[]; + }, + length: async (): Promise => (await createMockStore(path).entries()).length, + clear: async (): Promise => { + recordE2eCall('store', 'clear', { path }); + data.clear(); + }, + has: async (key: string): Promise => { + recordE2eCall('store', 'has', { path, key }); + return data.has(key) || initialValue(path, key) !== undefined; + }, + onKeyChange: (key: string, callback: (value: T | null) => void): (() => void) => { + recordE2eCall('store', 'onKeyChange', { path, key }); + const listenersForPath = keyListeners.get(path) ?? new Map>(); + const listenersForKey = listenersForPath.get(key) ?? new Set(); + listenersForKey.add(callback as KeyListener); + listenersForPath.set(key, listenersForKey); + keyListeners.set(path, listenersForPath); + return () => listenersForKey.delete(callback as KeyListener); + }, + onChange: (callback: (key: string, value: T | null) => void): (() => void) => { + recordE2eCall('store', 'onChange', { path }); + const listenersForPath = changeListeners.get(path) ?? new Set(); + listenersForPath.add(callback as ChangeListener); + changeListeners.set(path, listenersForPath); + return () => listenersForPath.delete(callback as ChangeListener); + }, + reset: async (): Promise => { + recordE2eCall('store', 'reset', { path }); + data.clear(); + }, + close: async (): Promise => { + recordE2eCall('store', 'close', { path }); + }, }; } -export async function load(_path: string, _options?: unknown) { - return createMockStore(); +export async function load(path: string, _options?: unknown) { + recordE2eCall('store', 'load', { path }); + return createMockStore(path); } -export async function Store(_path: string, _options?: unknown) { - return createMockStore(); +export async function Store(path: string, _options?: unknown) { + recordE2eCall('store', 'constructor', { path }); + return createMockStore(path); } From c339ff57edb6e9eb847309f32911dcf2d6bf3776 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Wed, 2 Sep 2026 11:20:20 +0900 Subject: [PATCH 03/16] fix: preserve structured plugin failure codes Decode structured Tauri error payloads after wrapper serialization so plugin failures retain their stable UI classification. --- src/lib/plugin-install-errors.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/plugin-install-errors.ts b/src/lib/plugin-install-errors.ts index 0cce9481..d38f7f9a 100644 --- a/src/lib/plugin-install-errors.ts +++ b/src/lib/plugin-install-errors.ts @@ -86,9 +86,17 @@ function decodeErrorText(value: unknown, depth = 0): string | undefined { return undefined; } + if (Object.prototype.hasOwnProperty.call(FAILURE_DETAILS, text)) { + return text as PluginInstallFailureCode; + } + try { const decoded = JSON.parse(text) as unknown; if (!(typeof decoded === 'string' && decoded === text)) { + const decodedCode = readKnownCode(decoded, depth + 1); + if (decodedCode) { + return decodedCode; + } const decodedText = decodeErrorText(decoded, depth + 1); if (decodedText) { return decodedText; @@ -128,6 +136,19 @@ function readKnownCode(value: unknown, depth = 0): PluginInstallFailureCode | un return value as PluginInstallFailureCode; } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown; + const parsedCode = readKnownCode(parsed, depth + 1); + if (parsedCode) return parsedCode; + } catch { + const embeddedCode = value.match(/"code"\s*:\s*"([^"]+)"/i)?.[1]; + if (embeddedCode && Object.prototype.hasOwnProperty.call(FAILURE_DETAILS, embeddedCode)) { + return embeddedCode as PluginInstallFailureCode; + } + } + } + if (value !== null && typeof value === 'object') { const code = readProperty(value, 'code'); if (typeof code === 'string' && Object.prototype.hasOwnProperty.call(FAILURE_DETAILS, code)) { From 59950c2217681ab43e9949f5fc4360a734bedbf7 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Wed, 2 Sep 2026 11:21:15 +0900 Subject: [PATCH 04/16] test: cover application feature flows with Playwright Add deterministic stateful Tauri mocks and screen-level coverage for app shell, server management, console, users, files, backups, settings, properties, Java, Ngrok, and proxy flows. --- src/renderer/components/AddServerModal.tsx | 12 +- src/renderer/components/BackupsView.tsx | 7 +- src/renderer/components/ConsoleView.tsx | 13 +- src/renderer/components/DashboardView.tsx | 2 +- src/renderer/components/FilesView.tsx | 7 +- src/renderer/components/ImportServerModal.tsx | 3 + src/renderer/components/JavaManagerModal.tsx | 2 + src/renderer/components/NgrokGuideView.tsx | 2 +- src/renderer/components/ProxyHelpView.tsx | 2 +- src/renderer/components/ProxySetupView.tsx | 2 +- src/renderer/components/SettingsWindow.tsx | 281 +++++++++--------- src/renderer/components/UsersView.tsx | 11 +- .../components/properties/PropertiesView.tsx | 2 +- .../components/properties/ServerSettings.tsx | 8 +- tests/e2e/app-shell.spec.ts | 62 ++++ tests/e2e/console-users.spec.ts | 42 +++ tests/e2e/files-backups.spec.ts | 63 ++++ tests/e2e/navigation.spec.ts | 8 - tests/e2e/proxy-java-ngrok.spec.ts | 52 ++++ tests/e2e/server-management.spec.ts | 63 ++++ tests/e2e/settings-properties.spec.ts | 75 +++++ tests/e2e/support/app-fixture.ts | 8 + tests/e2e/support/e2e-runtime.ts | 2 + tests/mocks/tauri-api-core.ts | 9 +- tests/mocks/tauri-api-webview-window.ts | 40 ++- tests/mocks/tauri-api-window.ts | 1 + tests/mocks/tauri-plugin-http.ts | 3 +- 27 files changed, 610 insertions(+), 172 deletions(-) create mode 100644 tests/e2e/app-shell.spec.ts create mode 100644 tests/e2e/console-users.spec.ts create mode 100644 tests/e2e/files-backups.spec.ts create mode 100644 tests/e2e/proxy-java-ngrok.spec.ts create mode 100644 tests/e2e/server-management.spec.ts create mode 100644 tests/e2e/settings-properties.spec.ts diff --git a/src/renderer/components/AddServerModal.tsx b/src/renderer/components/AddServerModal.tsx index fab93b59..dcdd59e6 100644 --- a/src/renderer/components/AddServerModal.tsx +++ b/src/renderer/components/AddServerModal.tsx @@ -202,7 +202,11 @@ const AddServerModal: FC = ({ open: isOpen, onClose, onAdd,
- @@ -223,7 +227,11 @@ const AddServerModal: FC = ({ open: isOpen, onClose, onAdd,
- {VERSION_OPTIONS.map((v) => (