Compare commits

...

6 Commits

Author SHA1 Message Date
dante01yoon
021c17912f fix: use immediate watcher to handle already-terminal downloads
Vue watch() is lazy by default — if the WS completion event
arrives before the watcher is registered, the callback never
fires. Adding { immediate: true } settles the existing state
on watcher creation.
2026-04-03 23:34:28 +09:00
dante01yoon
84c6ef1236 fix: add concurrency guard and watcher lifecycle cleanup
- Add isUploading early-return guard in uploadModel() to prevent
  duplicate uploads from rapid double-clicks
- Hoist stopWatch to composable scope so resetWizard() can clean
  up in-flight watchers that would otherwise run on a stale wizard
2026-04-03 20:37:41 +09:00
dante01yoon
ad6f22447c fix: localize fallback download error message
Use existing i18n key assetBrowser.downloadFailed instead of
hardcoded English string per project conventions.
2026-04-03 19:55:35 +09:00
dante01yoon
d73088273c fix: handle failed async downloads in upload dialog watcher
Watch the download task's status directly instead of only
lastCompletedDownload, so failed/errored tasks also transition
the dialog out of 'processing' state.
2026-04-03 19:10:38 +09:00
dante01yoon
2f194851b8 fix: update upload dialog status when async download completes
Watch assetDownloadStore.lastCompletedDownload so the wizard
transitions from 'processing' to 'success' once the tracked
async task finishes.
2026-04-03 18:43:47 +09:00
dante01yoon
00b876bab5 test: add failing test for upload dialog stuck in processing state
When an async model upload completes via WebSocket, the wizard's
uploadStatus remains 'processing' because it never watches
assetDownloadStore.lastCompletedDownload for completion events.
2026-04-03 18:41:57 +09:00
2 changed files with 219 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
name: 'Civitai',
hostnames: ['civitai.com'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
name: 'HuggingFace',
hostnames: ['huggingface.co'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path)
}
}))
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback,
t: (key: string) => key,
te: () => false,
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-123',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download completes
const downloadStore = useAssetDownloadStore()
downloadStore.$patch({})
const detail = {
task_id: 'task-123',
asset_id: 'asset-456',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 1000,
progress: 100,
status: 'completed' as const
}
// Directly call the store's internal handler via the event system
const event = new CustomEvent('asset_download', { detail })
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
// If handler was registered, call it; otherwise set store state directly
if (handler) {
handler(event)
} else {
// Manually update store state as WS handler would
downloadStore.downloadList.push?.({
taskId: 'task-123',
assetId: 'asset-456',
assetName: 'model.safetensors',
bytesTotal: 1000,
bytesDownloaded: 1000,
progress: 100,
status: 'completed',
lastUpdate: Date.now(),
modelType: 'checkpoints'
})
downloadStore.$patch({
lastCompletedDownload: {
taskId: 'task-123',
modelType: 'checkpoints',
timestamp: Date.now()
}
})
}
await nextTick()
// BUG: uploadStatus should be 'success' but remains 'processing'
expect(wizard.uploadStatus.value).toBe('success')
})
it('updates uploadStatus to error when async download fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-fail',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download fails
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
const failEvent = new CustomEvent('asset_download', {
detail: {
task_id: 'task-fail',
asset_id: '',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const,
error: 'Network error'
}
})
if (handler) {
handler(failEvent)
}
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Network error')
})
})

View File

@@ -36,6 +36,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
url: '',
@@ -203,6 +204,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
if (!canUploadModel.value) {
return false
}
@@ -247,6 +249,35 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
)
}
uploadStatus.value = 'processing'
stopAsyncWatch?.()
stopAsyncWatch = watch(
() =>
assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)?.status,
async (status) => {
if (status === 'completed') {
uploadStatus.value = 'success'
await refreshModelCaches()
stopAsyncWatch?.()
stopAsyncWatch = undefined
} else if (status === 'failed') {
const download = assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)
uploadStatus.value = 'error'
uploadError.value =
download?.error ||
t('assetBrowser.downloadFailed', {
name: download?.assetName || ''
})
stopAsyncWatch?.()
stopAsyncWatch = undefined
}
},
{ immediate: true }
)
} else {
uploadStatus.value = 'success'
await refreshModelCaches()
@@ -271,6 +302,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
function resetWizard() {
stopAsyncWatch?.()
stopAsyncWatch = undefined
currentStep.value = 1
isFetchingMetadata.value = false
isUploading.value = false