Files
ComfyUI_frontend/src/extensions/core/uploadAudio.test.ts
jaeone94 41b258e023 [backport cloud/1.43] fix: clear media upload errors via widget change (#12212) (#12246)
## Summary

Backports #12212 to `cloud/1.43` so paste/drop media uploads clear
missing media validation rings through the widget change path.

## Changes

- **What**: Cherry-picked `95e616b89404fe2bda5be4dc6194aeac07a65a0b` and
adapted the Vue node error E2E conflict for the 1.43 test helper API.
- **Dependencies**: None.

## Review Focus

Conflict resolution was limited to
`browser_tests/tests/vueNodes/nodeStates/error.spec.ts`. The 1.43 branch
does not have the newer Vue node error helpers from `main`, so the
backport keeps the existing 1.43 tests and adds only the Load Image
drop/paste missing-media clearing coverage using local prompt route
mocking plus `getNodeLocator()` / `getWidget(0)`.

Backport of #12212.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12246-backport-cloud-1-43-fix-clear-media-upload-errors-via-widget-change-12212-3606d73d365081cd9074d2bb0c1e9e74)
by [Unito](https://www.unito.io)
2026-05-14 21:09:42 +09:00

249 lines
7.2 KiB
TypeScript

import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
const { mockAddAlert, mockApiURL, mockFetchApi, mockRegisterExtension } =
vi.hoisted(() => ({
mockAddAlert: vi.fn(),
mockApiURL: vi.fn((url: string) => `api:${url}`),
mockFetchApi: vi.fn(),
mockRegisterExtension: vi.fn()
}))
let capturedDragDrop: ((files: File[]) => Promise<File[] | never[]>) | undefined
let capturedFileSelect:
| ((files: File[]) => Promise<File[] | never[]>)
| undefined
let capturedPaste: ((files: File[]) => Promise<File[] | never[]>) | undefined
type AudioUploadWidget = (node: LGraphNode, inputName: string) => unknown
vi.mock('extendable-media-recorder', () => ({
MediaRecorder: class MockMediaRecorder {}
}))
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
options: { onDrop: typeof capturedDragDrop }
) => {
capturedDragDrop = options.onDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: (
_node: LGraphNode,
options: { onSelect: typeof capturedFileSelect }
) => {
capturedFileSelect = options.onSelect
return { openFileSelection: vi.fn() }
}
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: (
_node: LGraphNode,
options: { onPaste: typeof capturedPaste }
) => {
capturedPaste = options.onPaste
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: mockAddAlert })
}))
vi.mock('@/renderer/extensions/vueNodes/widgets/utils/audioUtils', () => ({
getResourceURL: (subfolder = '', filename = '', type = 'input') =>
`/view?filename=${filename}&subfolder=${subfolder}&type=${type}`,
splitFilePath: (path: string) => ['', path, 'input']
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: mockApiURL,
fetchApi: mockFetchApi
}
}))
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: mockRegisterExtension,
rootGraph: { id: 'root' }
}
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: vi.fn()
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn()
}))
vi.mock('@/services/audioService', () => ({
useAudioService: () => ({})
}))
function createFile(name = 'clip.mp3'): File {
return new File(['audio'], name, { type: 'audio/mpeg' })
}
function successResponse(name: string, subfolder?: string) {
return {
status: 200,
json: () => Promise.resolve({ name, subfolder })
}
}
function failResponse(status = 500) {
return {
status,
statusText: 'Server Error'
}
}
function createAudioNode() {
const audioWidget = {
name: 'audio',
value: 'previous.mp3',
options: { values: ['previous.mp3'] },
callback: vi.fn()
}
const audioUIWidget = {
name: 'audioUI',
element: document.createElement('audio'),
value: '',
callback: vi.fn()
}
const uploadWidget = { label: '', serialize: true, canvasOnly: false }
const node = fromAny<LGraphNode, unknown>({
widgets: [audioWidget, audioUIWidget],
isUploading: false,
graph: { setDirtyCanvas: vi.fn() },
addWidget: vi.fn(() => uploadWidget),
onWidgetChanged: vi.fn()
})
return { audioUIWidget, audioWidget, node, uploadWidget }
}
async function loadAudioUploadWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.UploadAudio')
if (!extension)
throw new Error('Comfy.UploadAudio extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUploadWidget>).AUDIOUPLOAD
}
describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDragDrop = undefined
capturedFileSelect = undefined
capturedPaste = undefined
})
it('sets isUploading while upload is in progress and clears it after success', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
let resolveUpload: (response: ReturnType<typeof successResponse>) => void
mockFetchApi.mockReturnValueOnce(
new Promise((resolve) => {
resolveUpload = resolve
})
)
const upload = capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(true)
expect(audioWidget.value).toBe('clip.mp3')
resolveUpload!(successResponse('uploaded.mp3', 'pasted'))
await upload
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('pasted/uploaded.mp3')
expect(audioWidget.options.values).toContain('pasted/uploaded.mp3')
expect(node.onWidgetChanged).toHaveBeenCalledWith(
'audio',
'pasted/uploaded.mp3',
'clip.mp3',
audioWidget
)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rejects concurrent audio uploads without starting another request', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
node.isUploading = true
const result = await capturedDragDrop!([createFile()])
expect(result).toEqual([])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('rolls back the widget value and clears isUploading when upload fails', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
mockFetchApi.mockResolvedValueOnce(failResponse())
await capturedPaste!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith('500 - Server Error')
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rolls back the widget value and clears isUploading when upload throws synchronously', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const error = new Error('Upload failed before request promise')
mockFetchApi.mockImplementationOnce(() => {
throw error
})
await capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith(error)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('returns early when no files are provided', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const result = await capturedFileSelect!([])
expect(result).toEqual([])
expect(node.isUploading).toBe(false)
expect(mockFetchApi).not.toHaveBeenCalled()
})
})