= {}
+): 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'
+ )
+ })
})