mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 12:42:01 +00:00
fix: show all outputs in FormDropdown for multi-output jobs (#10131)
## Summary FormDropdown Outputs tab only showed the first output for multi-output jobs because the Jobs API `/jobs` returns a single `preview_output` per job. ## Changes - **What**: When history assets include jobs with `outputs_count > 1`, lazily fetch full outputs via `getJobDetail` (cached in `jobOutputCache`) and expand them into individual dropdown items. Single-output jobs are unaffected. Added in-flight guard to prevent duplicate fetches. - This is a consumer-side workaround in `WidgetSelectDropdown.vue` that becomes a no-op once the backend returns all outputs in the list response (planned Assets API migration). ## Review Focus - The `resolvedMultiOutputs` shallowRef + watch pattern for async data feeding into a computed. Each `getJobDetail` call is cached by `jobOutputCache` LRU, so no redundant network requests. - This fix is intentionally temporary — it will be superseded when OSS/cloud both return full outputs from list endpoints. ## No E2E test E2E coverage is impractical here: reproducing requires a running ComfyUI backend executing a workflow that produces multiple outputs, then inspecting the FormDropdown's Outputs tab. The unit test covers the lazy-loading logic with mocked `getJobDetail` responses. Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -55,6 +55,33 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
mockMediaAssets: {
|
||||
media: ref([]),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
},
|
||||
mockResolveOutputAssetItems: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -484,6 +511,229 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
interface MultiOutputInstance extends ComponentPublicInstance {
|
||||
outputItems: FormDropdownItem[]
|
||||
}
|
||||
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountMultiOutput(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
}) as unknown as VueWrapper<MultiOutputInstance>
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
createMockWidget<string | undefined>({
|
||||
value: 'output_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaAssets.media.value = []
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
})
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview output when job has only one output', () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'single.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
const wrapper = mountMultiOutput(widget, 'single.png')
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
|
||||
Reference in New Issue
Block a user