Increase coverage tests.

This commit is contained in:
Talmaj Marinc
2026-07-01 13:13:38 +02:00
parent 6a3afc42c6
commit ffe0760430
14 changed files with 1873 additions and 7 deletions

View File

@@ -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

View File

@@ -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 }))

View File

@@ -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: '<div><slot /></div>' },
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: defineComponent({
emits: ['open-auto-focus'],
mounted() {
this.$emit('open-auto-focus')
},
template: '<div><slot /></div>'
}),
DialogHeader: { template: '<div><slot /></div>' },
DialogTitle: { template: '<div><slot /></div>' },
DialogDescription: { template: '<div><slot /></div>' },
DialogFooter: { template: '<div><slot /></div>' },
SingleSelect: {
props: ['modelValue', 'options', 'label', 'loading'],
emits: ['update:modelValue'],
template: `
<select
data-testid="folder-select"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', $event.target.value)"
>
<option value="" disabled>{{ label }}</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
`
}
}
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()
})
})
})

View File

@@ -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<HostCredentialView[]>([]),
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<void>
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: '<div><slot /></div>' },
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: defineComponent({
emits: ['open-auto-focus'],
mounted() {
this.$emit('open-auto-focus')
},
template: '<div><slot /></div>'
}),
DialogHeader: { template: '<div><slot /></div>' },
DialogTitle: { template: '<div><slot /></div>' },
DialogDescription: { template: '<div><slot /></div>' },
SingleSelect: {
props: ['modelValue', 'options', 'label'],
emits: ['update:modelValue'],
template: `
<select
data-testid="scheme-select"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
`
}
}
function createCredential(
overrides: Partial<HostCredentialView> = {}
): 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<typeof showConfirmDialog>
)
})
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()
})
})

View File

@@ -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<typeof ModelDownloadStoreModule>()
return {
...actual,
useModelDownloadStore: () => ({ removeFromView: mockRemoveFromView })
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function createDownload(
overrides: Partial<DownloadStatus> = {}
): 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')
})
})
})

View File

@@ -79,7 +79,7 @@
class="relative flex items-center justify-between gap-2 text-xs text-muted-foreground"
>
<span>{{ statusLabel }}</span>
<span class="truncate">{{ metaLine }}</span>
<span class="truncate" data-testid="meta-line">{{ metaLine }}</span>
</div>
<p

View File

@@ -0,0 +1,175 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { DownloadStatus } from '../types'
import ModelManagerSidebarTab from './ModelManagerSidebarTab.vue'
const mockHydrate = vi.fn()
const mockClearHistory = vi.fn()
const mockStore = reactive({
downloadList: ref<DownloadStatus[]>([]),
activeDownloads: ref<DownloadStatus[]>([]),
historyDownloads: ref<DownloadStatus[]>([]),
hydrate: mockHydrate,
clearHistory: mockClearHistory
})
vi.mock('../stores/modelDownloadStore', () => ({
useModelDownloadStore: () => mockStore
}))
vi.mock('./ModelDownloadRow.vue', () => ({
default: {
name: 'ModelDownloadRow',
props: ['download'],
emits: ['openCredentials'],
template:
'<div><span>{{ download.download_id }}</span>' +
'<button @click="$emit(\'openCredentials\', download.model_id)">open-credentials</button></div>'
}
}))
vi.mock('./AddModelByUrlDialog.vue', () => ({
default: {
name: 'AddModelByUrlDialog',
props: ['open'],
template: '<div data-testid="add-model-dialog">{{ open }}</div>'
}
}))
vi.mock('./DownloadCredentialsDialog.vue', () => ({
default: {
name: 'DownloadCredentialsDialog',
props: ['open', 'prefillHost'],
template:
'<div data-testid="credentials-dialog">{{ open }}:{{ prefillHost }}</div>'
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template: '<div><slot name="tool-buttons" /><slot name="body" /></div>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function createDownload(
overrides: Partial<DownloadStatus> = {}
): 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'
)
})
})

View File

@@ -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<void>
onCancel?: () => void
}
}
function capturedOptions(): CapturedConfirmOptions {
return mockShowConfirmDialog.mock
.calls[0][0] as unknown as CapturedConfirmOptions
}
function createDownload(
overrides: Partial<DownloadStatus> = {}
): 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<typeof showConfirmDialog>
)
})
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()
})
})
})

View File

@@ -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<CompletedDownload | null>(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()
})
})

View File

@@ -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')
})
})

View File

@@ -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> = {}
): 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()
})
})
})

View File

@@ -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' }))

View File

@@ -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()

View File

@@ -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'
)
})
})