diff --git a/src/platform/missingModel/missingModelDownload.test.ts b/src/platform/missingModel/missingModelDownload.test.ts index cca8acc214..31ddceaaa8 100644 --- a/src/platform/missingModel/missingModelDownload.test.ts +++ b/src/platform/missingModel/missingModelDownload.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DownloadApiError } from '@/platform/modelManager/types' + import { downloadModel, fetchModelMetadata, @@ -55,6 +57,19 @@ vi.mock('@/platform/updates/common/toastStore', () => ({ useToastStore: () => ({ add: mockToastAdd }) })) +const mockRefreshModelFolder = vi.fn() +const mockRefreshMissingModels = vi.fn() + +vi.mock('@/stores/modelStore', () => ({ + useModelStore: () => ({ refreshModelFolder: mockRefreshModelFolder }) +})) + +vi.mock('@/platform/missingModel/missingModelStore', () => ({ + useMissingModelStore: () => ({ + refreshMissingModels: mockRefreshMissingModels + }) +})) + let testId = 0 beforeEach(() => { @@ -140,6 +155,26 @@ describe('fetchModelMetadata', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('returns null metadata when the Civitai request throws', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')) + + const metadata = await fetchModelMetadata( + `https://civitai.com/api/download/models/${testId}` + ) + + expect(metadata).toEqual({ fileSize: null, gatedRepoUrl: null }) + }) + + it('returns null metadata when the HEAD request throws', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')) + + const metadata = await fetchModelMetadata( + `https://huggingface.co/org/model/resolve/main/throws-${testId}.safetensors` + ) + + expect(metadata).toEqual({ fileSize: null, gatedRepoUrl: null }) + }) + it('returns cached metadata on second call', async () => { const url = `https://huggingface.co/org/model/resolve/main/cached-${testId}.safetensors` @@ -263,6 +298,16 @@ describe('isModelDownloadable', () => { }) ).toBe(false) }) + + it('allows explicitly whitelisted URLs from an otherwise disallowed host', () => { + expect( + isModelDownloadable({ + name: 'RealESRGAN_x4plus.pth', + url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth', + directory: 'upscale_models' + }) + ).toBe(true) + }) }) describe('downloadModel', () => { @@ -467,6 +512,87 @@ describe('downloadModel', () => { expect(mockSidebarTabStore.activeSidebarTabId).toBeNull() }) + it('reveals the download manager and shows an info toast for an in-progress download', async () => { + mockFlags.serverSideModelDownloads = true + mockEnqueue.mockRejectedValue( + new DownloadApiError('exists', 'ALREADY_DOWNLOADING', 409) + ) + + downloadModel( + { + name: 'model.safetensors', + url: 'https://huggingface.co/org/model/resolve/main/model.safetensors', + directory: 'checkpoints' + }, + { checkpoints: ['/models/checkpoints'] } + ) + + await vi.waitFor(() => { + expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-manager') + }) + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'info', + detail: 'model.safetensors' + }) + ) + }) + + it('refreshes the model folder and re-scans missing models when already available', async () => { + mockFlags.serverSideModelDownloads = true + mockEnqueue.mockRejectedValue( + new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409) + ) + mockRefreshModelFolder.mockResolvedValue(undefined) + + downloadModel( + { + name: 'model.safetensors', + url: 'https://huggingface.co/org/model/resolve/main/model.safetensors', + directory: 'checkpoints' + }, + { checkpoints: ['/models/checkpoints'] } + ) + + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'info', + detail: 'model.safetensors' + }) + ) + }) + await vi.waitFor(() => { + expect(mockRefreshModelFolder).toHaveBeenCalledWith('checkpoints') + }) + expect(mockRefreshMissingModels).toHaveBeenCalled() + expect(mockSidebarTabStore.activeSidebarTabId).toBeNull() + }) + + it('still re-scans missing models when the post-available folder refresh fails', async () => { + mockFlags.serverSideModelDownloads = true + mockEnqueue.mockRejectedValue( + new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409) + ) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockRefreshModelFolder.mockRejectedValue(new Error('boom')) + + downloadModel( + { + name: 'model.safetensors', + url: 'https://huggingface.co/org/model/resolve/main/model.safetensors', + directory: 'checkpoints' + }, + { checkpoints: ['/models/checkpoints'] } + ) + + await vi.waitFor(() => { + expect(mockRefreshMissingModels).toHaveBeenCalled() + }) + expect(consoleWarn).toHaveBeenCalled() + consoleWarn.mockRestore() + }) + it('opens the model library sidebar before starting a desktop download', () => { mockIsDesktop.value = true diff --git a/src/platform/modelManager/api/modelDownloadApi.test.ts b/src/platform/modelManager/api/modelDownloadApi.test.ts index f4d0ad0bf3..f11937ff2f 100644 --- a/src/platform/modelManager/api/modelDownloadApi.test.ts +++ b/src/platform/modelManager/api/modelDownloadApi.test.ts @@ -10,6 +10,8 @@ import { enqueueDownload, listCredentials, listDownloads, + pauseDownload, + resumeDownload, setDownloadPriority, upsertCredential } from './modelDownloadApi' @@ -31,6 +33,15 @@ function jsonResponse(status: number, body: unknown): Response { } as unknown as Response } +function nonJsonErrorResponse(status: number, statusText: string): Response { + return { + ok: false, + status, + statusText, + json: () => Promise.reject(new Error('not json')) + } as unknown as Response +} + describe('modelDownloadApi', () => { beforeEach(() => { vi.resetAllMocks() @@ -70,6 +81,18 @@ describe('modelDownloadApi', () => { }) }) + it('falls back to statusText and an UNKNOWN code for a non-JSON error body', async () => { + fetchApi.mockResolvedValue(nonJsonErrorResponse(502, 'Bad Gateway')) + + await expect( + enqueueDownload({ url: 'u', model_id: 'loras/x.safetensors' }) + ).rejects.toMatchObject({ + message: 'Bad Gateway', + code: 'UNKNOWN', + status: 502 + }) + }) + it('exposes the code through DownloadApiError.is()', async () => { fetchApi.mockResolvedValue( jsonResponse(400, { @@ -100,6 +123,28 @@ describe('modelDownloadApi', () => { }) describe('actions', () => { + it('posts to the pause route', async () => { + fetchApi.mockResolvedValue(jsonResponse(200, { ok: true })) + + await pauseDownload('d1') + + expect(fetchApi).toHaveBeenCalledWith( + '/download/d1/pause', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('posts to the resume route', async () => { + fetchApi.mockResolvedValue(jsonResponse(200, { ok: true })) + + await resumeDownload('d1') + + expect(fetchApi).toHaveBeenCalledWith( + '/download/d1/resume', + expect.objectContaining({ method: 'POST' }) + ) + }) + it('posts to the cancel route', async () => { fetchApi.mockResolvedValue(jsonResponse(200, { ok: true })) diff --git a/src/platform/modelManager/components/AddModelByUrlDialog.test.ts b/src/platform/modelManager/components/AddModelByUrlDialog.test.ts new file mode 100644 index 0000000000..5baadb4425 --- /dev/null +++ b/src/platform/modelManager/components/AddModelByUrlDialog.test.ts @@ -0,0 +1,257 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import { DownloadApiError } from '../types' +import AddModelByUrlDialog from './AddModelByUrlDialog.vue' + +const mockEnqueue = vi.fn() +const mockToastAdd = vi.fn() +const mockFetchModelTypes = vi.fn() +const mockModelTypes = ref([ + { name: 'Checkpoints', value: 'checkpoints' }, + { name: 'LoRA', value: 'loras' } +]) + +vi.mock('../stores/modelDownloadStore', () => ({ + useModelDownloadStore: () => ({ enqueue: mockEnqueue }) +})) + +vi.mock('@/platform/assets/composables/useModelTypes', () => ({ + useModelTypes: () => ({ + modelTypes: mockModelTypes, + isLoading: ref(false), + fetchModelTypes: mockFetchModelTypes + }) +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: mockToastAdd }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages }, + missingWarn: false, + fallbackWarn: false +}) + +const stubs = { + Dialog: { template: '
' }, + DialogPortal: { template: '
' }, + DialogOverlay: { template: '
' }, + DialogContent: defineComponent({ + emits: ['open-auto-focus'], + mounted() { + this.$emit('open-auto-focus') + }, + template: '
' + }), + DialogHeader: { template: '
' }, + DialogTitle: { template: '
' }, + DialogDescription: { template: '
' }, + DialogFooter: { template: '
' }, + SingleSelect: { + props: ['modelValue', 'options', 'label', 'loading'], + emits: ['update:modelValue'], + template: ` + + ` + } +} + +function mountDialog(open = true) { + return render(AddModelByUrlDialog, { + props: { open }, + global: { plugins: [i18n], stubs } + }) +} + +async function fillValidForm() { + await userEvent.type( + screen.getByLabelText('URL'), + 'https://huggingface.co/org/model/resolve/main/model.safetensors' + ) + await userEvent.selectOptions( + screen.getByTestId('folder-select'), + 'checkpoints' + ) +} + +describe('AddModelByUrlDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('auto-fills the filename from the url until edited manually', async () => { + mountDialog() + const urlInput = screen.getByLabelText('URL') + const filenameInput = screen.getByLabelText('Filename') + + await userEvent.type( + urlInput, + 'https://huggingface.co/org/model/resolve/main/model.safetensors' + ) + expect(filenameInput).toHaveValue('model.safetensors') + + await userEvent.clear(filenameInput) + await userEvent.type(filenameInput, 'custom.safetensors') + await userEvent.type(urlInput, '?download=true') + + expect(filenameInput).toHaveValue('custom.safetensors') + }) + + it('shows a hint for a url on a non-allowlisted host', async () => { + mountDialog() + + await userEvent.type( + screen.getByLabelText('URL'), + 'https://example.com/model.safetensors' + ) + + expect( + screen.getByText('This host may not be on the download allowlist.') + ).toBeInTheDocument() + }) + + it('disables submit until url, folder, and a valid filename are set', async () => { + mountDialog() + const submit = screen.getByRole('button', { name: 'Download' }) + expect(submit).toBeDisabled() + + await userEvent.type( + screen.getByLabelText('URL'), + 'https://huggingface.co/org/model/resolve/main/model.safetensors' + ) + expect(submit).toBeDisabled() + + await userEvent.selectOptions( + screen.getByTestId('folder-select'), + 'checkpoints' + ) + expect(submit).toBeEnabled() + }) + + it('requires a known model extension unless allow-any-extension is checked', async () => { + mountDialog() + await userEvent.type( + screen.getByLabelText('URL'), + 'https://huggingface.co/org/model/resolve/main/readme.txt' + ) + await userEvent.selectOptions( + screen.getByTestId('folder-select'), + 'checkpoints' + ) + const submit = screen.getByRole('button', { name: 'Download' }) + expect(submit).toBeDisabled() + + await userEvent.click(screen.getByRole('checkbox')) + + expect(submit).toBeEnabled() + }) + + it('closes without submitting when cancel is clicked', async () => { + const { emitted } = mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(mockEnqueue).not.toHaveBeenCalled() + expect(emitted('update:open')?.at(-1)).toEqual([false]) + }) + + it('enqueues the download and closes on success', async () => { + mockEnqueue.mockResolvedValue({ download_id: 'd1', accepted: true }) + const { emitted } = mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Download' })) + + expect(mockEnqueue).toHaveBeenCalledWith({ + url: 'https://huggingface.co/org/model/resolve/main/model.safetensors', + model_id: 'checkpoints/model.safetensors', + allow_any_extension: false + }) + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + }) + expect(emitted('update:open')?.at(-1)).toEqual([false]) + }) + + it('shows an info toast and closes when the model is already downloading', async () => { + mockEnqueue.mockRejectedValue( + new DownloadApiError('exists', 'ALREADY_DOWNLOADING', 409) + ) + const { emitted } = mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Download' })) + + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'info' }) + ) + }) + expect(emitted('update:open')?.at(-1)).toEqual([false]) + }) + + it('shows an info toast and closes when the model is already installed', async () => { + mockEnqueue.mockRejectedValue( + new DownloadApiError('already there', 'ALREADY_AVAILABLE', 409) + ) + const { emitted } = mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Download' })) + + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'info' }) + ) + }) + expect(emitted('update:open')?.at(-1)).toEqual([false]) + }) + + it('shows an inline error for other API failures and stays open', async () => { + mockEnqueue.mockRejectedValue( + new DownloadApiError('url not allowed', 'URL_NOT_ALLOWED', 400) + ) + const { emitted } = mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Download' })) + + await vi.waitFor(() => { + expect(screen.getByText('url not allowed')).toBeInTheDocument() + }) + expect(emitted('update:open')).toBeUndefined() + }) + + it('shows a generic error message for a non-api error', async () => { + mockEnqueue.mockRejectedValue(new Error('network down')) + mountDialog() + await fillValidForm() + + await userEvent.click(screen.getByRole('button', { name: 'Download' })) + + await vi.waitFor(() => { + expect(screen.getByText('network down')).toBeInTheDocument() + }) + }) +}) diff --git a/src/platform/modelManager/components/DownloadCredentialsDialog.test.ts b/src/platform/modelManager/components/DownloadCredentialsDialog.test.ts new file mode 100644 index 0000000000..e5820b7088 --- /dev/null +++ b/src/platform/modelManager/components/DownloadCredentialsDialog.test.ts @@ -0,0 +1,313 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, reactive, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog' +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import type { HostCredentialView } from '../types' +import DownloadCredentialsDialog from './DownloadCredentialsDialog.vue' + +const mockCloseDialog = vi.fn() +const mockToastAdd = vi.fn() + +const mockCredentialsStore = reactive({ + credentials: ref([]), + isLoading: ref(false), + fetchCredentials: vi.fn(), + upsert: vi.fn(), + remove: vi.fn() +}) + +vi.mock('../stores/downloadCredentialsStore', () => ({ + useDownloadCredentialsStore: () => mockCredentialsStore +})) + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: () => ({ closeDialog: mockCloseDialog }) +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: mockToastAdd }) +})) + +vi.mock('@/components/dialog/confirm/confirmDialog') + +const mockShowConfirmDialog = vi.mocked(showConfirmDialog) + +interface CapturedConfirmOptions { + footerProps?: { + onConfirm?: () => void | Promise + onCancel?: () => void + } +} + +function capturedOptions(): CapturedConfirmOptions { + return mockShowConfirmDialog.mock + .calls[0][0] as unknown as CapturedConfirmOptions +} + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages }, + missingWarn: false, + fallbackWarn: false +}) + +const stubs = { + Dialog: { template: '
' }, + DialogPortal: { template: '
' }, + DialogOverlay: { template: '
' }, + DialogContent: defineComponent({ + emits: ['open-auto-focus'], + mounted() { + this.$emit('open-auto-focus') + }, + template: '
' + }), + DialogHeader: { template: '
' }, + DialogTitle: { template: '
' }, + DialogDescription: { template: '
' }, + SingleSelect: { + props: ['modelValue', 'options', 'label'], + emits: ['update:modelValue'], + template: ` + + ` + } +} + +function createCredential( + overrides: Partial = {} +): HostCredentialView { + return { + id: 'c1', + host: 'huggingface.co', + auth_scheme: 'bearer', + header_name: null, + query_param: null, + label: null, + match_subdomains: false, + enabled: true, + secret_last4: '1234', + created_at: 0, + updated_at: 0, + ...overrides + } +} + +function mountDialog(props: { open?: boolean; prefillHost?: string } = {}) { + return render(DownloadCredentialsDialog, { + props: { open: true, ...props }, + global: { plugins: [i18n], stubs } + }) +} + +describe('DownloadCredentialsDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCredentialsStore.credentials = [] + mockCredentialsStore.fetchCredentials.mockResolvedValue(undefined) + mockShowConfirmDialog.mockReturnValue( + {} as ReturnType + ) + }) + + it('renders existing credentials with host, scheme, and last 4 of the secret', () => { + mockCredentialsStore.credentials = [ + createCredential({ + host: 'huggingface.co', + auth_scheme: 'bearer', + secret_last4: '9f21' + }) + ] + mountDialog() + + expect( + screen.getByText('huggingface.co · Bearer token · ••••9f21') + ).toBeInTheDocument() + }) + + it('shows a disabled marker for a disabled credential', () => { + mockCredentialsStore.credentials = [createCredential({ enabled: false })] + mountDialog() + + expect(screen.getByText(/Disabled/)).toBeInTheDocument() + }) + + it('prefills the host from the prefillHost prop on open', async () => { + mountDialog({ prefillHost: 'civitai.com' }) + await nextTick() + + expect(screen.getByLabelText('Host')).toHaveValue('civitai.com') + }) + + it('populates the form when editing an existing credential', async () => { + mockCredentialsStore.credentials = [ + createCredential({ + id: 'c1', + host: 'civitai.com', + auth_scheme: 'header', + header_name: 'X-Api-Key', + label: 'My Civitai key' + }) + ] + mountDialog() + + await userEvent.click(screen.getByTitle('Edit')) + + expect(screen.getByLabelText('Host')).toHaveValue('civitai.com') + expect(screen.getByLabelText('Label')).toHaveValue('My Civitai key') + expect(screen.getByText('Update credential')).toBeInTheDocument() + }) + + it('disables submit until host and secret are filled', async () => { + mountDialog() + const submit = screen.getByRole('button', { name: 'Save' }) + expect(submit).toBeDisabled() + + await userEvent.type(screen.getByLabelText('Host'), 'huggingface.co') + expect(submit).toBeDisabled() + + await userEvent.type(screen.getByLabelText('API key'), 's3cret') + expect(submit).toBeEnabled() + }) + + it('requires a query parameter name when the scheme is query', async () => { + mountDialog() + await userEvent.type(screen.getByLabelText('Host'), 'huggingface.co') + await userEvent.type(screen.getByLabelText('API key'), 's3cret') + await userEvent.selectOptions(screen.getByTestId('scheme-select'), 'query') + + const submit = screen.getByRole('button', { name: 'Save' }) + expect(submit).toBeDisabled() + + await userEvent.type(screen.getByLabelText('Query parameter'), 'token') + expect(submit).toBeEnabled() + }) + + it('shows the header name field and a subdomain warning for the header scheme', async () => { + mountDialog() + + await userEvent.selectOptions(screen.getByTestId('scheme-select'), 'header') + expect(screen.getByLabelText('Header name')).toBeInTheDocument() + + await userEvent.click(screen.getByLabelText('Match subdomains')) + expect( + screen.getByText( + 'Not recommended: hubs redirect to sibling CDN hosts that must not receive your key.' + ) + ).toBeInTheDocument() + }) + + it('cancels an in-progress edit and resets the form', async () => { + mockCredentialsStore.credentials = [ + createCredential({ id: 'c1', host: 'civitai.com' }) + ] + mountDialog() + await userEvent.click(screen.getByTitle('Edit')) + expect(screen.getByText('Update credential')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(screen.getByText('Add credential')).toBeInTheDocument() + expect(screen.getByLabelText('Host')).toHaveValue('') + }) + + it('submits the form payload and resets on success', async () => { + mockCredentialsStore.upsert.mockResolvedValue(createCredential()) + mountDialog() + + await userEvent.type(screen.getByLabelText('Host'), 'huggingface.co') + await userEvent.type(screen.getByLabelText('API key'), 's3cret') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(mockCredentialsStore.upsert).toHaveBeenCalledWith({ + host: 'huggingface.co', + secret: 's3cret', + auth_scheme: 'bearer', + header_name: null, + query_param: null, + label: null, + match_subdomains: false + }) + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + }) + expect(screen.getByLabelText('Host')).toHaveValue('') + }) + + it('shows an inline error message when saving fails', async () => { + mockCredentialsStore.upsert.mockRejectedValue(new Error('save failed')) + mountDialog() + + await userEvent.type(screen.getByLabelText('Host'), 'huggingface.co') + await userEvent.type(screen.getByLabelText('API key'), 's3cret') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + await vi.waitFor(() => { + expect(screen.getByText('save failed')).toBeInTheDocument() + }) + }) + + it('shows an inline error when the initial credentials fetch fails', async () => { + mockCredentialsStore.fetchCredentials.mockRejectedValue( + new Error('offline') + ) + mountDialog() + + await vi.waitFor(() => { + expect(screen.getByText('offline')).toBeInTheDocument() + }) + }) + + it('deletes a credential through a confirm dialog', async () => { + mockCredentialsStore.remove.mockResolvedValue(undefined) + mockCredentialsStore.credentials = [createCredential({ id: 'c1' })] + mountDialog() + + await userEvent.click(screen.getByTitle('Delete')) + await capturedOptions().footerProps?.onConfirm?.() + + expect(mockCredentialsStore.remove).toHaveBeenCalledWith('c1') + expect(mockCloseDialog).toHaveBeenCalled() + }) + + it('shows an error toast when deletion fails', async () => { + mockCredentialsStore.remove.mockRejectedValue(new Error('boom')) + mockCredentialsStore.credentials = [createCredential({ id: 'c1' })] + mountDialog() + + await userEvent.click(screen.getByTitle('Delete')) + await capturedOptions().footerProps?.onConfirm?.() + + await vi.waitFor(() => { + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ) + }) + }) + + it('does not delete when the confirm dialog is dismissed', async () => { + mockCredentialsStore.credentials = [createCredential({ id: 'c1' })] + mountDialog() + + await userEvent.click(screen.getByTitle('Delete')) + capturedOptions().footerProps?.onCancel?.() + + expect(mockCredentialsStore.remove).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/modelManager/components/ModelDownloadRow.test.ts b/src/platform/modelManager/components/ModelDownloadRow.test.ts new file mode 100644 index 0000000000..7d19534ae1 --- /dev/null +++ b/src/platform/modelManager/components/ModelDownloadRow.test.ts @@ -0,0 +1,248 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import type * as ModelDownloadStoreModule from '../stores/modelDownloadStore' +import type { DownloadStatus } from '../types' +import ModelDownloadRow from './ModelDownloadRow.vue' + +const mockPause = vi.fn() +const mockResume = vi.fn() +const mockCancel = vi.fn() +const mockRaisePriority = vi.fn() +const mockRemoveFromView = vi.fn() + +vi.mock('../composables/useModelDownloadActions', () => ({ + useModelDownloadActions: () => ({ + pause: mockPause, + resume: mockResume, + cancel: mockCancel, + raisePriority: mockRaisePriority, + toastError: vi.fn() + }) +})) + +vi.mock('../stores/modelDownloadStore', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useModelDownloadStore: () => ({ removeFromView: mockRemoveFromView }) + } +}) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages }, + missingWarn: false, + fallbackWarn: false +}) + +function createDownload( + overrides: Partial = {} +): DownloadStatus { + return { + download_id: 'd1', + model_id: 'loras/x.safetensors', + url: 'https://huggingface.co/org/x.safetensors', + status: 'active', + priority: 0, + total_bytes: 2048, + bytes_done: 1024, + progress: 0.5, + speed_bps: 512, + eta_seconds: 125, + segments: null, + error: null, + created_at: 0, + updated_at: 0, + ...overrides + } +} + +function mountRow( + download: DownloadStatus, + onOpenCredentials?: (host: string) => void +) { + return render(ModelDownloadRow, { + props: { + download, + ...(onOpenCredentials ? { onOpenCredentials } : {}) + }, + global: { plugins: [i18n] } + }) +} + +describe('ModelDownloadRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('splits the model id into directory and filename', () => { + mountRow(createDownload({ model_id: 'loras/x.safetensors' })) + + expect(screen.getByText('x.safetensors')).toBeInTheDocument() + expect(screen.getByText('loras')).toBeInTheDocument() + }) + + it('renders an empty directory when the model id has no folder', () => { + mountRow(createDownload({ model_id: 'x.safetensors' })) + + expect(screen.getByText('x.safetensors')).toBeInTheDocument() + }) + + it('formats the meta line with percent, size, speed, and eta while active', () => { + mountRow(createDownload({ status: 'active' })) + + expect( + screen.getByText('50% · 1 KB / 2 KB · 512 B/s · 2:05') + ).toBeInTheDocument() + }) + + it('renders an empty meta line when no progress metrics are known yet', () => { + mountRow( + createDownload({ + status: 'queued', + progress: null, + total_bytes: null, + bytes_done: 0, + speed_bps: null, + eta_seconds: null + }) + ) + + expect(screen.getByTestId('meta-line')).toBeEmptyDOMElement() + }) + + it('omits the eta once a download is no longer active', () => { + mountRow( + createDownload({ + status: 'paused', + progress: 0.5, + eta_seconds: 125 + }) + ) + + expect(screen.getByText('50% · 1 KB / 2 KB · 512 B/s')).toBeInTheDocument() + }) + + describe('action buttons by status', () => { + it('shows pause and cancel for a queued download, plus raise priority', () => { + mountRow(createDownload({ status: 'queued' })) + + expect(screen.getByTitle('Raise priority')).toBeInTheDocument() + expect(screen.getByTitle('Pause')).toBeInTheDocument() + expect(screen.getByTitle('Cancel')).toBeInTheDocument() + expect(screen.queryByTitle('Resume')).not.toBeInTheDocument() + expect(screen.queryByTitle('Remove from list')).not.toBeInTheDocument() + }) + + it('shows only resume for a failed download without an auth error', () => { + mountRow(createDownload({ status: 'failed', error: 'disk full' })) + + expect(screen.getByTitle('Resume')).toBeInTheDocument() + expect(screen.queryByTitle('Pause')).not.toBeInTheDocument() + expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument() + expect(screen.queryByTitle('Remove from list')).not.toBeInTheDocument() + expect(screen.queryByTitle('Add credentials')).not.toBeInTheDocument() + }) + + it('shows the remove action for terminal downloads', () => { + mountRow(createDownload({ status: 'completed' })) + + expect(screen.getByTitle('Remove from list')).toBeInTheDocument() + expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument() + expect(screen.queryByTitle('Resume')).not.toBeInTheDocument() + }) + }) + + describe('auth errors', () => { + it('shows the credentials button and a host-specific hint', async () => { + const onOpenCredentials = vi.fn() + mountRow( + createDownload({ + status: 'failed', + url: 'https://huggingface.co/org/x.safetensors', + error: '401 Unauthorized' + }), + onOpenCredentials + ) + + expect( + screen.getByText( + 'huggingface.co needs an API key. Add one in the Credentials Manager, then resume.' + ) + ).toBeInTheDocument() + + await userEvent.click(screen.getByTitle('Add credentials')) + expect(onOpenCredentials).toHaveBeenCalledWith('huggingface.co') + }) + + it('falls back to a hostless hint when the url cannot be parsed', () => { + mountRow( + createDownload({ + status: 'failed', + url: 'not-a-url', + error: '403 forbidden' + }) + ) + + expect( + screen.getByText( + 'This host/model needs an API key. Add one in the Credentials Manager, then resume.' + ) + ).toBeInTheDocument() + }) + + it('shows the raw error message for a non-auth failure', () => { + mountRow(createDownload({ status: 'failed', error: 'disk full' })) + + expect(screen.getByText('disk full')).toBeInTheDocument() + expect(screen.queryByTitle('Add credentials')).not.toBeInTheDocument() + }) + }) + + describe('user actions', () => { + it('pauses on click', async () => { + const download = createDownload({ status: 'active' }) + mountRow(download) + + await userEvent.click(screen.getByTitle('Pause')) + expect(mockPause).toHaveBeenCalledWith(download) + }) + + it('resumes on click', async () => { + const download = createDownload({ status: 'paused' }) + mountRow(download) + + await userEvent.click(screen.getByTitle('Resume')) + expect(mockResume).toHaveBeenCalledWith(download) + }) + + it('cancels on click', async () => { + const download = createDownload({ status: 'active' }) + mountRow(download) + + await userEvent.click(screen.getByTitle('Cancel')) + expect(mockCancel).toHaveBeenCalledWith(download) + }) + + it('raises priority by 1 on click', async () => { + const download = createDownload({ status: 'queued', priority: 2 }) + mountRow(download) + + await userEvent.click(screen.getByTitle('Raise priority')) + expect(mockRaisePriority).toHaveBeenCalledWith(download, 1) + }) + + it('removes from view on click', async () => { + mountRow(createDownload({ download_id: 'd1', status: 'completed' })) + + await userEvent.click(screen.getByTitle('Remove from list')) + expect(mockRemoveFromView).toHaveBeenCalledWith('d1') + }) + }) +}) diff --git a/src/platform/modelManager/components/ModelDownloadRow.vue b/src/platform/modelManager/components/ModelDownloadRow.vue index 85d166bf0f..0f455756fa 100644 --- a/src/platform/modelManager/components/ModelDownloadRow.vue +++ b/src/platform/modelManager/components/ModelDownloadRow.vue @@ -79,7 +79,7 @@ class="relative flex items-center justify-between gap-2 text-xs text-muted-foreground" > {{ statusLabel }} - {{ metaLine }} + {{ metaLine }}

([]), + activeDownloads: ref([]), + historyDownloads: ref([]), + hydrate: mockHydrate, + clearHistory: mockClearHistory +}) + +vi.mock('../stores/modelDownloadStore', () => ({ + useModelDownloadStore: () => mockStore +})) + +vi.mock('./ModelDownloadRow.vue', () => ({ + default: { + name: 'ModelDownloadRow', + props: ['download'], + emits: ['openCredentials'], + template: + '

{{ download.download_id }}' + + '
' + } +})) + +vi.mock('./AddModelByUrlDialog.vue', () => ({ + default: { + name: 'AddModelByUrlDialog', + props: ['open'], + template: '
{{ open }}
' + } +})) + +vi.mock('./DownloadCredentialsDialog.vue', () => ({ + default: { + name: 'DownloadCredentialsDialog', + props: ['open', 'prefillHost'], + template: + '
{{ open }}:{{ prefillHost }}
' + } +})) + +vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({ + default: { + name: 'SidebarTabTemplate', + template: '
' + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages }, + missingWarn: false, + fallbackWarn: false +}) + +function createDownload( + overrides: Partial = {} +): DownloadStatus { + return { + download_id: 'd1', + model_id: 'loras/x.safetensors', + url: 'https://huggingface.co/org/x.safetensors', + status: 'active', + priority: 0, + total_bytes: null, + bytes_done: 0, + progress: null, + speed_bps: null, + eta_seconds: null, + segments: null, + error: null, + created_at: 0, + updated_at: 0, + ...overrides + } +} + +function mountTab() { + return render(ModelManagerSidebarTab, { global: { plugins: [i18n] } }) +} + +describe('ModelManagerSidebarTab', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStore.downloadList = [] + mockStore.activeDownloads = [] + mockStore.historyDownloads = [] + }) + + it('hydrates the store on mount', () => { + mountTab() + expect(mockHydrate).toHaveBeenCalled() + }) + + it('shows the empty state when there are no downloads', () => { + mountTab() + expect(screen.getByText('No downloads yet')).toBeInTheDocument() + }) + + it('renders active downloads under the Active section', () => { + const download = createDownload({ download_id: 'd1' }) + mockStore.activeDownloads = [download] + mockStore.downloadList = [download] + mountTab() + + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.getByText('d1')).toBeInTheDocument() + expect(screen.queryByText('No downloads yet')).not.toBeInTheDocument() + }) + + it('renders history downloads under the History section with a clear action', async () => { + const download = createDownload({ download_id: 'd2' }) + mockStore.historyDownloads = [download] + mockStore.downloadList = [download] + mountTab() + + expect(screen.getByText('History')).toBeInTheDocument() + expect(screen.getByText('d2')).toBeInTheDocument() + + await userEvent.click(screen.getByText('Clear history')) + expect(mockClearHistory).toHaveBeenCalled() + }) + + it('opens the add-model dialog from the toolbar button', async () => { + mountTab() + + expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('false') + await userEvent.click(screen.getByTitle('Add model')) + expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('true') + }) + + it('opens the add-model dialog from the empty state button', async () => { + mountTab() + + await userEvent.click(screen.getByText('Add model')) + expect(screen.getByTestId('add-model-dialog')).toHaveTextContent('true') + }) + + it('opens the credentials dialog with an empty prefill from the toolbar button', async () => { + mountTab() + + await userEvent.click(screen.getByTitle('Credentials Manager')) + expect(screen.getByTestId('credentials-dialog')).toHaveTextContent('true:') + }) + + it('opens the credentials dialog prefilled with the row host', async () => { + const download = createDownload({ + download_id: 'd1', + model_id: 'loras/x.safetensors' + }) + mockStore.activeDownloads = [download] + mockStore.downloadList = [download] + mountTab() + + await userEvent.click(screen.getByText('open-credentials')) + + expect(screen.getByTestId('credentials-dialog')).toHaveTextContent( + 'true:loras/x.safetensors' + ) + }) +}) diff --git a/src/platform/modelManager/composables/useModelDownloadActions.test.ts b/src/platform/modelManager/composables/useModelDownloadActions.test.ts new file mode 100644 index 0000000000..0d1ad88570 --- /dev/null +++ b/src/platform/modelManager/composables/useModelDownloadActions.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog' + +import type { DownloadStatus } from '../types' +import { DownloadApiError } from '../types' +import { useModelDownloadActions } from './useModelDownloadActions' + +const mockStore = { + pause: vi.fn(), + resume: vi.fn(), + cancel: vi.fn(), + setPriority: vi.fn(), + hydrate: vi.fn() +} +const mockToastAdd = vi.fn() +const mockCloseDialog = vi.fn() + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) +})) + +vi.mock('../stores/modelDownloadStore', () => ({ + useModelDownloadStore: () => mockStore +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: mockToastAdd }) +})) + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: () => ({ closeDialog: mockCloseDialog }) +})) + +vi.mock('@/components/dialog/confirm/confirmDialog') + +const mockShowConfirmDialog = vi.mocked(showConfirmDialog) + +interface CapturedConfirmOptions { + footerProps?: { + confirmVariant?: string + onConfirm?: () => void | Promise + onCancel?: () => void + } +} + +function capturedOptions(): CapturedConfirmOptions { + return mockShowConfirmDialog.mock + .calls[0][0] as unknown as CapturedConfirmOptions +} + +function createDownload( + overrides: Partial = {} +): DownloadStatus { + return { + download_id: 'd1', + model_id: 'loras/x.safetensors', + url: 'https://huggingface.co/x.safetensors', + status: 'active', + priority: 0, + total_bytes: null, + bytes_done: 0, + progress: null, + speed_bps: null, + eta_seconds: null, + segments: null, + error: null, + created_at: 0, + updated_at: 0, + ...overrides + } +} + +describe('useModelDownloadActions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockShowConfirmDialog.mockReturnValue( + {} as ReturnType + ) + }) + + it('pauses a download by id', async () => { + mockStore.pause.mockResolvedValue(undefined) + const { pause } = useModelDownloadActions() + + await pause(createDownload({ download_id: 'd1' })) + + expect(mockStore.pause).toHaveBeenCalledWith('d1') + }) + + it('resumes a download by id', async () => { + mockStore.resume.mockResolvedValue(undefined) + const { resume } = useModelDownloadActions() + + await resume(createDownload({ download_id: 'd1' })) + + expect(mockStore.resume).toHaveBeenCalledWith('d1') + }) + + it('raises priority relative to the current value', async () => { + mockStore.setPriority.mockResolvedValue(undefined) + const { raisePriority } = useModelDownloadActions() + + await raisePriority(createDownload({ download_id: 'd1', priority: 2 }), 1) + + expect(mockStore.setPriority).toHaveBeenCalledWith('d1', 3) + }) + + it('shows an error toast and re-hydrates the store when an action fails', async () => { + mockStore.pause.mockRejectedValue(new Error('offline')) + mockStore.hydrate.mockResolvedValue(undefined) + const { pause } = useModelDownloadActions() + + await pause(createDownload()) + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error', detail: 'offline' }) + ) + expect(mockStore.hydrate).toHaveBeenCalled() + }) + + it('uses the DownloadApiError message in the toast', async () => { + mockStore.pause.mockRejectedValue( + new DownloadApiError('nope', 'URL_NOT_ALLOWED', 400) + ) + mockStore.hydrate.mockResolvedValue(undefined) + const { pause } = useModelDownloadActions() + + await pause(createDownload()) + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'nope' }) + ) + }) + + it('swallows a failed re-hydrate after an action error', async () => { + mockStore.pause.mockRejectedValue(new Error('offline')) + mockStore.hydrate.mockRejectedValue(new Error('still offline')) + const { pause } = useModelDownloadActions() + + await expect(pause(createDownload())).resolves.toBeUndefined() + }) + + describe('cancel', () => { + it('opens a destructive confirm dialog and only cancels on confirm', async () => { + mockStore.cancel.mockResolvedValue(undefined) + const { cancel } = useModelDownloadActions() + + cancel(createDownload({ download_id: 'd1' })) + expect(capturedOptions().footerProps?.confirmVariant).toBe('destructive') + + await capturedOptions().footerProps?.onConfirm?.() + + expect(mockStore.cancel).toHaveBeenCalledWith('d1') + expect(mockCloseDialog).toHaveBeenCalled() + }) + + it('does not cancel when the confirm dialog is dismissed', () => { + const { cancel } = useModelDownloadActions() + + cancel(createDownload({ download_id: 'd1' })) + capturedOptions().footerProps?.onCancel?.() + + expect(mockStore.cancel).not.toHaveBeenCalled() + expect(mockCloseDialog).toHaveBeenCalled() + }) + }) +}) diff --git a/src/platform/modelManager/composables/useModelDownloadEffects.test.ts b/src/platform/modelManager/composables/useModelDownloadEffects.test.ts new file mode 100644 index 0000000000..2ff14603bd --- /dev/null +++ b/src/platform/modelManager/composables/useModelDownloadEffects.test.ts @@ -0,0 +1,99 @@ +import { ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useModelDownloadEffects } from './useModelDownloadEffects' + +interface CompletedDownload { + downloadId: string + modelId: string + directory: string + timestamp: number +} + +const mockLastCompletedDownload = ref(null) +const mockRefreshModelFolder = vi.fn() +const mockRefreshMissingModels = vi.fn() + +vi.mock('../stores/modelDownloadStore', () => ({ + useModelDownloadStore: () => ({ + get lastCompletedDownload() { + return mockLastCompletedDownload.value + } + }) +})) + +vi.mock('@/stores/modelStore', () => ({ + useModelStore: () => ({ refreshModelFolder: mockRefreshModelFolder }) +})) + +vi.mock('@/platform/missingModel/missingModelStore', () => ({ + useMissingModelStore: () => ({ + refreshMissingModels: mockRefreshMissingModels + }) +})) + +describe('useModelDownloadEffects', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLastCompletedDownload.value = null + mockRefreshModelFolder.mockResolvedValue(undefined) + }) + + it('does nothing until a download completes', () => { + useModelDownloadEffects() + + expect(mockRefreshModelFolder).not.toHaveBeenCalled() + expect(mockRefreshMissingModels).not.toHaveBeenCalled() + }) + + it('refreshes the model folder and re-scans missing models on completion', async () => { + useModelDownloadEffects() + + mockLastCompletedDownload.value = { + downloadId: 'd1', + modelId: 'loras/x.safetensors', + directory: 'loras', + timestamp: 1 + } + + await vi.waitFor(() => { + expect(mockRefreshModelFolder).toHaveBeenCalledWith('loras') + }) + expect(mockRefreshMissingModels).toHaveBeenCalled() + }) + + it('skips the folder refresh when the directory is unknown', async () => { + useModelDownloadEffects() + + mockLastCompletedDownload.value = { + downloadId: 'd1', + modelId: 'unknown.safetensors', + directory: '', + timestamp: 1 + } + + await vi.waitFor(() => { + expect(mockRefreshMissingModels).toHaveBeenCalled() + }) + expect(mockRefreshModelFolder).not.toHaveBeenCalled() + }) + + it('still re-scans missing models when the folder refresh fails', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockRefreshModelFolder.mockRejectedValue(new Error('boom')) + useModelDownloadEffects() + + mockLastCompletedDownload.value = { + downloadId: 'd1', + modelId: 'loras/x.safetensors', + directory: 'loras', + timestamp: 1 + } + + await vi.waitFor(() => { + expect(mockRefreshMissingModels).toHaveBeenCalled() + }) + expect(consoleWarn).toHaveBeenCalled() + consoleWarn.mockRestore() + }) +}) diff --git a/src/platform/modelManager/composables/useModelManagerSidebarTab.test.ts b/src/platform/modelManager/composables/useModelManagerSidebarTab.test.ts new file mode 100644 index 0000000000..4f475b08ef --- /dev/null +++ b/src/platform/modelManager/composables/useModelManagerSidebarTab.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +const mockActiveDownloadCount = { value: 0 } + +vi.mock('../stores/modelDownloadStore', () => ({ + useModelDownloadStore: () => ({ + get activeDownloadCount() { + return mockActiveDownloadCount.value + } + }) +})) + +vi.mock('../components/ModelManagerSidebarTab.vue', () => ({ + default: { name: 'ModelManagerSidebarTab' } +})) + +import { useModelManagerSidebarTab } from './useModelManagerSidebarTab' + +describe('useModelManagerSidebarTab', () => { + it('returns the expected sidebar tab extension shape', () => { + const tab = useModelManagerSidebarTab() + + expect(tab.id).toBe('model-manager') + expect(tab.type).toBe('vue') + expect(tab.title).toBe('modelManager.title') + expect(tab.tooltip).toBe('modelManager.title') + expect(tab.label).toBe('modelManager.title') + }) + + it('shows no badge when there are no active downloads', () => { + mockActiveDownloadCount.value = 0 + const tab = useModelManagerSidebarTab() + + expect(typeof tab.iconBadge).toBe('function') + expect((tab.iconBadge as () => string | null)()).toBeNull() + }) + + it('shows the active download count as a badge', () => { + mockActiveDownloadCount.value = 3 + const tab = useModelManagerSidebarTab() + + expect((tab.iconBadge as () => string | null)()).toBe('3') + }) +}) diff --git a/src/platform/modelManager/stores/downloadCredentialsStore.test.ts b/src/platform/modelManager/stores/downloadCredentialsStore.test.ts new file mode 100644 index 0000000000..9e06962c11 --- /dev/null +++ b/src/platform/modelManager/stores/downloadCredentialsStore.test.ts @@ -0,0 +1,169 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import * as downloadApi from '../api/modelDownloadApi' +import type { HostCredentialUpsert, HostCredentialView } from '../types' +import { useDownloadCredentialsStore } from './downloadCredentialsStore' + +vi.mock('../api/modelDownloadApi', () => ({ + listCredentials: vi.fn(), + upsertCredential: vi.fn(), + deleteCredential: vi.fn() +})) + +function createCredential( + overrides: Partial = {} +): HostCredentialView { + return { + id: 'c1', + host: 'huggingface.co', + auth_scheme: 'bearer', + header_name: null, + query_param: null, + label: null, + match_subdomains: false, + enabled: true, + secret_last4: '1234', + created_at: 0, + updated_at: 0, + ...overrides + } +} + +describe('useDownloadCredentialsStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetAllMocks() + }) + + describe('fetchCredentials', () => { + it('toggles isLoading and stores the result', async () => { + const credential = createCredential() + vi.mocked(downloadApi.listCredentials).mockResolvedValue([credential]) + const store = useDownloadCredentialsStore() + + const promise = store.fetchCredentials() + expect(store.isLoading).toBe(true) + await promise + + expect(store.isLoading).toBe(false) + expect(store.credentials).toEqual([credential]) + }) + + it('clears isLoading even when the request fails', async () => { + vi.mocked(downloadApi.listCredentials).mockRejectedValue( + new Error('boom') + ) + const store = useDownloadCredentialsStore() + + await expect(store.fetchCredentials()).rejects.toThrow('boom') + expect(store.isLoading).toBe(false) + }) + }) + + describe('upsert', () => { + it('normalizes the host and appends a new credential', async () => { + const created = createCredential({ id: 'new', host: 'huggingface.co' }) + vi.mocked(downloadApi.upsertCredential).mockResolvedValue(created) + const store = useDownloadCredentialsStore() + const body: HostCredentialUpsert = { + host: ' HuggingFace.co ', + secret: 's3cret' + } + + const result = await store.upsert(body) + + expect(downloadApi.upsertCredential).toHaveBeenCalledWith( + expect.objectContaining({ host: 'huggingface.co', secret: 's3cret' }) + ) + expect(result).toEqual(created) + expect(store.credentials).toEqual([created]) + }) + + it('replaces an existing credential by id', async () => { + const original = createCredential({ id: 'c1', label: 'Old' }) + const updated = createCredential({ id: 'c1', label: 'New' }) + vi.mocked(downloadApi.upsertCredential).mockResolvedValue(updated) + const store = useDownloadCredentialsStore() + store.credentials.push(original) + + await store.upsert({ host: 'huggingface.co', secret: 's' }) + + expect(store.credentials).toEqual([updated]) + }) + }) + + describe('remove', () => { + it('deletes and filters out the credential by id', async () => { + vi.mocked(downloadApi.deleteCredential).mockResolvedValue(undefined) + const store = useDownloadCredentialsStore() + store.credentials.push( + createCredential({ id: 'c1' }), + createCredential({ id: 'c2' }) + ) + + await store.remove('c1') + + expect(downloadApi.deleteCredential).toHaveBeenCalledWith('c1') + expect(store.credentials.map((c) => c.id)).toEqual(['c2']) + }) + }) + + describe('enabledCredentialForHost', () => { + it('matches an exact enabled host case-insensitively', () => { + const store = useDownloadCredentialsStore() + store.credentials.push( + createCredential({ host: 'HuggingFace.co', enabled: true }) + ) + + expect(store.enabledCredentialForHost('huggingface.co')?.host).toBe( + 'HuggingFace.co' + ) + }) + + it('returns undefined for a disabled exact match', () => { + const store = useDownloadCredentialsStore() + store.credentials.push( + createCredential({ host: 'huggingface.co', enabled: false }) + ) + + expect(store.enabledCredentialForHost('huggingface.co')).toBeUndefined() + }) + + it('falls back to a subdomain match only when match_subdomains is set', () => { + const store = useDownloadCredentialsStore() + store.credentials.push( + createCredential({ + host: 'huggingface.co', + match_subdomains: true, + enabled: true + }) + ) + + expect(store.enabledCredentialForHost('cdn.huggingface.co')?.host).toBe( + 'huggingface.co' + ) + }) + + it('does not subdomain-match when match_subdomains is false', () => { + const store = useDownloadCredentialsStore() + store.credentials.push( + createCredential({ + host: 'huggingface.co', + match_subdomains: false, + enabled: true + }) + ) + + expect( + store.enabledCredentialForHost('cdn.huggingface.co') + ).toBeUndefined() + }) + + it('returns undefined when no host matches', () => { + const store = useDownloadCredentialsStore() + expect(store.enabledCredentialForHost('example.com')).toBeUndefined() + }) + }) +}) diff --git a/src/platform/modelManager/stores/modelDownloadStore.test.ts b/src/platform/modelManager/stores/modelDownloadStore.test.ts index b30a79d5fe..3e116f0c6d 100644 --- a/src/platform/modelManager/stores/modelDownloadStore.test.ts +++ b/src/platform/modelManager/stores/modelDownloadStore.test.ts @@ -143,6 +143,112 @@ describe('useModelDownloadStore', () => { expect(downloadApi.pauseDownload).toHaveBeenCalledWith('d1') }) + it('optimistically updates status when resuming', async () => { + vi.mocked(downloadApi.resumeDownload).mockResolvedValue() + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'd1', status: 'paused' })) + + await store.resume('d1') + + expect(store.downloadList.find((d) => d.download_id === 'd1')?.status).toBe( + 'queued' + ) + expect(downloadApi.resumeDownload).toHaveBeenCalledWith('d1') + }) + + it('marks a download cancelled after the API call resolves', async () => { + vi.mocked(downloadApi.cancelDownload).mockResolvedValue() + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'd1', status: 'active' })) + + await store.cancel('d1') + + expect(store.downloadList.find((d) => d.download_id === 'd1')?.status).toBe( + 'cancelled' + ) + expect(downloadApi.cancelDownload).toHaveBeenCalledWith('d1') + }) + + it('optimistically updates priority and calls the API', async () => { + vi.mocked(downloadApi.setDownloadPriority).mockResolvedValue() + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'd1', status: 'queued', priority: 0 })) + + await store.setPriority('d1', 5) + + expect( + store.downloadList.find((d) => d.download_id === 'd1')?.priority + ).toBe(5) + expect(downloadApi.setDownloadPriority).toHaveBeenCalledWith('d1', 5) + }) + + it('is a no-op when patching priority for an unknown id', async () => { + vi.mocked(downloadApi.setDownloadPriority).mockResolvedValue() + const store = useModelDownloadStore() + + await store.setPriority('missing', 5) + + expect(store.downloadList).toHaveLength(0) + expect(downloadApi.setDownloadPriority).toHaveBeenCalledWith('missing', 5) + }) + + it('finds a download by model id', () => { + const store = useModelDownloadStore() + dispatch( + createStatus({ download_id: 'd1', model_id: 'loras/x.safetensors' }) + ) + + expect(store.findByModelId('loras/x.safetensors')?.download_id).toBe('d1') + expect(store.findByModelId('loras/missing.safetensors')).toBeUndefined() + }) + + describe('hydrate', () => { + it('replaces the download map with the fetched list', async () => { + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'stale', status: 'active' })) + vi.mocked(downloadApi.listDownloads).mockResolvedValue([ + createStatus({ download_id: 'fresh', status: 'active' }) + ]) + + await store.hydrate() + + expect(store.downloadList.map((d) => d.download_id)).toEqual(['fresh']) + }) + + it('records a completion when a refreshed row transitions to completed', async () => { + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'd1', status: 'active' })) + vi.mocked(downloadApi.listDownloads).mockResolvedValue([ + createStatus({ + download_id: 'd1', + model_id: 'loras/x.safetensors', + status: 'completed' + }) + ]) + + await store.hydrate() + + expect(store.lastCompletedDownload).toMatchObject({ + downloadId: 'd1', + modelId: 'loras/x.safetensors', + directory: 'loras' + }) + }) + + it('does not re-record a completion for a row that was already completed', async () => { + const store = useModelDownloadStore() + dispatch(createStatus({ download_id: 'd1', status: 'completed' })) + const firstTimestamp = store.lastCompletedDownload?.timestamp + vi.mocked(downloadApi.listDownloads).mockResolvedValue([ + createStatus({ download_id: 'd1', status: 'completed' }) + ]) + + await store.hydrate() + + expect(store.lastCompletedDownload?.timestamp).toBe(firstTimestamp) + }) + }) + it('records the last completed download once on the completing transition', () => { const store = useModelDownloadStore() dispatch(createStatus({ download_id: 'd1', status: 'active' })) diff --git a/src/stores/commandStore.test.ts b/src/stores/commandStore.test.ts index d02ea454c9..49b894ad57 100644 --- a/src/stores/commandStore.test.ts +++ b/src/stores/commandStore.test.ts @@ -113,6 +113,27 @@ describe('commandStore', () => { }) }) + describe('unregisterCommand', () => { + it('removes a registered command', () => { + const store = useCommandStore() + store.registerCommand({ id: 'to.remove', function: vi.fn() }) + + store.unregisterCommand('to.remove') + + expect(store.isRegistered('to.remove')).toBe(false) + }) + + it('is a no-op for an unregistered id', () => { + const store = useCommandStore() + store.registerCommand({ id: 'keep.me', function: vi.fn() }) + + store.unregisterCommand('nonexistent') + + expect(store.isRegistered('keep.me')).toBe(true) + expect(store.commands).toHaveLength(1) + }) + }) + describe('isRegistered', () => { it('returns false for unregistered command', () => { const store = useCommandStore() diff --git a/src/stores/workspace/sidebarTabStore.test.ts b/src/stores/workspace/sidebarTabStore.test.ts index cef96df72b..743e3f48d0 100644 --- a/src/stores/workspace/sidebarTabStore.test.ts +++ b/src/stores/workspace/sidebarTabStore.test.ts @@ -5,12 +5,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' -const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } = - vi.hoisted(() => ({ - mockGetSetting: vi.fn(), - mockRegisterCommand: vi.fn(), - mockRegisterCommands: vi.fn() - })) +const { + mockGetSetting, + mockRegisterCommand, + mockRegisterCommands, + mockUnregisterCommand, + mockServerSideModelDownloads +} = vi.hoisted(() => ({ + mockGetSetting: vi.fn(), + mockRegisterCommand: vi.fn(), + mockRegisterCommands: vi.fn(), + mockUnregisterCommand: vi.fn(), + mockServerSideModelDownloads: { value: false } +})) vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: () => ({ @@ -21,10 +28,43 @@ vi.mock('@/platform/settings/settingStore', () => ({ vi.mock('@/stores/commandStore', () => ({ useCommandStore: () => ({ registerCommand: mockRegisterCommand, + unregisterCommand: mockUnregisterCommand, commands: [] }) })) +vi.mock('@/composables/useFeatureFlags', async () => { + const { ref } = await import('vue') + const serverSideModelDownloadsRef = ref(mockServerSideModelDownloads.value) + Object.defineProperty(mockServerSideModelDownloads, 'value', { + get: () => serverSideModelDownloadsRef.value, + set: (value: boolean) => { + serverSideModelDownloadsRef.value = value + } + }) + return { + useFeatureFlags: () => ({ + flags: { + get serverSideModelDownloads() { + return mockServerSideModelDownloads.value + } + } + }) + } +}) + +vi.mock( + '@/platform/modelManager/composables/useModelManagerSidebarTab', + () => ({ + useModelManagerSidebarTab: () => ({ + id: 'model-manager', + title: 'model-manager', + type: 'vue', + component: {} + }) + }) +) + vi.mock('@/stores/menuItemStore', () => ({ useMenuItemStore: () => ({ registerCommands: mockRegisterCommands @@ -99,6 +139,8 @@ describe('useSidebarTabStore', () => { mockGetSetting.mockReset() mockRegisterCommand.mockClear() mockRegisterCommands.mockClear() + mockUnregisterCommand.mockClear() + mockServerSideModelDownloads.value = false }) it('registers the job history tab when QPO V2 is enabled', () => { @@ -160,4 +202,57 @@ describe('useSidebarTabStore', () => { ]) expect(mockRegisterCommand).toHaveBeenCalledTimes(6) }) + + it('registers the model-manager tab when the feature flag starts enabled', () => { + mockServerSideModelDownloads.value = true + + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + expect(store.sidebarTabs.map((tab) => tab.id)).toContain('model-manager') + }) + + it('does not register the model-manager tab when the feature flag is disabled', () => { + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'model-manager' + ) + }) + + it('registers the model-manager tab when the feature flag turns on later', async () => { + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'model-manager' + ) + + mockServerSideModelDownloads.value = true + await nextTick() + + expect(store.sidebarTabs.map((tab) => tab.id)).toContain('model-manager') + expect(mockRegisterCommand).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'Workspace.ToggleSidebarTab.model-manager' + }) + ) + }) + + it('unregisters the model-manager tab and its command when the flag turns off', async () => { + mockServerSideModelDownloads.value = true + const store = useSidebarTabStore() + store.registerCoreSidebarTabs() + expect(store.sidebarTabs.map((tab) => tab.id)).toContain('model-manager') + + mockServerSideModelDownloads.value = false + await nextTick() + + expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain( + 'model-manager' + ) + expect(mockUnregisterCommand).toHaveBeenCalledWith( + 'Workspace.ToggleSidebarTab.model-manager' + ) + }) })