Compare commits

..

4 Commits

Author SHA1 Message Date
GitHub Action
def709cd7d [automated] Apply ESLint and Oxfmt fixes 2026-03-24 02:13:53 +00:00
Matt Miller
e6a423c36e fix: unexport FILE_INPUT_FIELDS and fix lint
- Remove export from FILE_INPUT_FIELDS (flagged by knip as unused export)
- Extract EmptyFileInputNode interface to fix oxfmt line length

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:11:01 -07:00
Matt Miller
1b6c2f0add fix: address review findings for empty file input validation
- Broaden check to catch null, undefined, and whitespace-only values
- Skip linked inputs (array refs to upstream nodes) to avoid false positives
- Scope validation to target nodes during partial execution
- Use `as const` for FILE_INPUT_FIELDS
- Add test coverage for edge cases (null, undefined, whitespace, linked
  inputs, partial execution filtering, missing fields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:41:04 -07:00
Matt Miller
68c6a9d7e2 fix: block workflow queue when file input nodes have empty selections
Prevents submitting workflows where LoadImage, LoadAudio, Load3D, or
LoadVideo nodes have no file selected. Shows a localized error dialog
listing the affected nodes instead of sending an invalid request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:29:53 -07:00
9 changed files with 234 additions and 184 deletions

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-y border-solid border-neutral-700"
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="w-16 text-center">
<td class="text-center w-16">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="mx-2 inline-block"
class="inline-block mx-2"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="px-4 text-right">
<td class="text-right px-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -14,18 +14,25 @@ vi.mock('@/i18n', () => ({
const executionStore = reactive<{
isIdle: boolean
executionProgress: number
executingNode: null | {
title?: string
type?: string
}
executingNode: unknown
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activeJob: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
isIdle: true,
executionProgress: 0,
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {}
nodeProgressStates: {},
activeJob: null
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -69,6 +76,7 @@ describe('useBrowserTabTitle', () => {
executionStore.executingNode = null
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activeJob = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
@@ -176,12 +184,18 @@ describe('useBrowserTabTitle', () => {
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNode = {
type: 'Foo'
}
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activeJob = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()

View File

@@ -74,14 +74,14 @@ export const useBrowserTabTitle = () => {
}
// If only one node is running
const [, state] = runningNodes[0]
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeLabel =
executionStore.executingNode?.type?.trim() ||
executionStore.executingNode?.title?.trim() ||
'Node'
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeLabel}`
return `${executionText.value}[${progress}%] ${nodeType}`
})
const workflowTitle = computed(

View File

@@ -1889,7 +1889,9 @@
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"emptyFileInputTitle": "Missing File Inputs",
"emptyFileInputMessage": "The following nodes require a file to be selected: {nodeList}. Please upload or select files before running."
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -12,7 +12,6 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>

View File

@@ -146,6 +146,45 @@ import {
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
const FILE_INPUT_FIELDS = {
LoadImage: 'image',
LoadAudio: 'audio',
Load3D: 'model_file',
LoadVideo: 'video'
} as const
function isEmptyFileValue(value: unknown): boolean {
if (Array.isArray(value)) return false // linked input from another node
if (typeof value === 'string') return value.trim() === ''
return value == null
}
interface EmptyFileInputNode {
nodeId: string
classType: string
title: string
}
export function findEmptyFileInputNodes(
output: ComfyApiWorkflow,
nodeIds?: Set<string>
): EmptyFileInputNode[] {
const result: EmptyFileInputNode[] = []
for (const [nodeId, node] of Object.entries(output)) {
if (nodeIds && !nodeIds.has(nodeId)) continue
const field =
FILE_INPUT_FIELDS[node.class_type as keyof typeof FILE_INPUT_FIELDS]
if (field && isEmptyFileValue(node.inputs[field])) {
result.push({
nodeId,
classType: node.class_type,
title: node._meta?.title ?? node.class_type
})
}
}
return result
}
export function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
@@ -1615,6 +1654,25 @@ export class ComfyApp {
const queuedWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
const p = await this.graphToPrompt(this.rootGraph)
const targetNodeIds = isPartialExecution
? new Set(queueNodeIds!)
: undefined
const emptyFileInputNodes = findEmptyFileInputNodes(
p.output,
targetNodeIds
)
if (emptyFileInputNodes.length) {
const nodeList = emptyFileInputNodes
.map((n) => `#${n.nodeId} ${n.title}`)
.join(', ')
useDialogService().showErrorDialog(
new Error(t('errorDialog.emptyFileInputMessage', { nodeList })),
{ title: t('errorDialog.emptyFileInputTitle') }
)
break
}
const queuedNodes = collectAllNodes(this.rootGraph)
try {
api.authToken = comfyOrgAuthToken
@@ -1634,7 +1692,6 @@ export class ComfyApp {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { findEmptyFileInputNodes } from './app'
function makeNode(
classType: string,
inputs: Record<string, unknown>,
title?: string
) {
return {
class_type: classType,
inputs,
_meta: { title: title ?? classType }
}
}
describe('findEmptyFileInputNodes', () => {
it('detects LoadImage with empty image field', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('KSampler', { seed: 42 })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '1', classType: 'LoadImage', title: 'LoadImage' }
])
})
it('detects multiple empty file input nodes', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }, 'My Image'),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('LoadVideo', { video: 'file.mp4' })
}
const result = findEmptyFileInputNodes(output)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
nodeId: '1',
classType: 'LoadImage',
title: 'My Image'
})
expect(result[1]).toEqual({
nodeId: '2',
classType: 'LoadAudio',
title: 'LoadAudio'
})
})
it('returns empty array when all file inputs are populated', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: 'photo.png' }),
'2': makeNode('Load3D', { model_file: 'model.glb' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('returns empty array when no file input nodes exist', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('KSampler', { seed: 42 }),
'2': makeNode('CLIPTextEncode', { text: 'hello' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('detects Load3D with empty model_file', () => {
const output: ComfyApiWorkflow = {
'5': makeNode('Load3D', { model_file: '' })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '5', classType: 'Load3D', title: 'Load3D' }
])
})
it('detects null file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: null })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects undefined file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: undefined })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects whitespace-only file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ' ' })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('skips linked inputs (array references to other nodes)', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ['5', 0] })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('filters to only specified node IDs when provided', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('KSampler', { seed: 42 })
}
const result = findEmptyFileInputNodes(output, new Set(['2', '3']))
expect(result).toEqual([
{ nodeId: '2', classType: 'LoadAudio', title: 'LoadAudio' }
])
})
it('detects missing file input field entirely', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', {})
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
})

View File

@@ -1,22 +1,17 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
type WorkflowConstructor = typeof WorkflowStoreModule.ComfyWorkflow
// Create mock functions that will be shared
const mockNodeExecutionIdToNodeLocatorId = vi.fn()
const mockNodeIdToNodeLocatorId = vi.fn()
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
const workflowModuleState = vi.hoisted(() => ({
WorkflowClass: undefined as WorkflowConstructor | undefined
}))
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -25,7 +20,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
'@/platform/workflow/management/stores/workflowStore'
)
workflowModuleState.WorkflowClass = ComfyWorkflow
return {
ComfyWorkflow,
useWorkflowStore: vi.fn(() => ({
@@ -66,7 +60,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
})
@@ -90,29 +84,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
function createQueuedWorkflow(path: string = 'workflows/test.json') {
const { WorkflowClass } = workflowModuleState
if (!WorkflowClass) {
throw new Error('ComfyWorkflow mock class is not available')
}
return new WorkflowClass({
path,
modified: 0,
size: 0
})
}
function createPromptNode(title: string, classType: string) {
return {
inputs: {},
class_type: classType,
_meta: {
title
}
}
}
describe('useExecutionStore - NodeLocatorId conversions', () => {
let store: ReturnType<typeof useExecutionStore>
@@ -627,103 +598,6 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useExecutionStore - executingNode with subgraphs', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should find executing node info in root graph from queued prompt data', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'123': {
state: 'running',
value: 0,
max: 100,
display_node_id: '123',
prompt_id: 'test-prompt',
node_id: '123'
}
}
expect(store.executingNode).toEqual({
title: 'Test Node',
type: 'TestNode'
})
})
it('should find executing node info in subgraph using execution ID', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['456:789'],
promptOutput: {
'456:789': createPromptNode('Nested Node', 'NestedNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'456:789': {
state: 'running',
value: 0,
max: 100,
display_node_id: '456:789',
prompt_id: 'test-prompt',
node_id: '456:789'
}
}
expect(store.executingNode).toEqual({
title: 'Nested Node',
type: 'NestedNode'
})
})
it('should return null when no node is executing', () => {
store.nodeProgressStates = {}
expect(store.executingNode).toBeNull()
})
it('should return null when executing node metadata cannot be found', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'999': {
state: 'running',
value: 0,
max: 100,
display_node_id: '999',
prompt_id: 'test-prompt',
node_id: '999'
}
}
expect(store.executingNode).toBeNull()
})
})
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>

View File

@@ -7,7 +7,8 @@ import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyApiWorkflow,
ComfyNode,
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -33,11 +34,6 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
interface ExecutionNodeInfo {
title?: string | null
type?: string | null
}
interface QueuedJob {
/**
* The nodes that are queued to be executed. The key is the node id and the
@@ -48,25 +44,6 @@ interface QueuedJob {
* The workflow that is queued to be executed
*/
workflow?: ComfyWorkflow
/**
* Queue-time node metadata keyed by execution ID.
* This stays stable even if the user switches workflows or edits the canvas.
*/
nodeLookup?: Record<string, ExecutionNodeInfo>
}
function buildExecutionNodeLookup(
promptOutput: ComfyApiWorkflow
): Record<string, ExecutionNodeInfo> {
return Object.fromEntries(
Object.entries(promptOutput).map(([executionId, node]) => [
executionId,
{
title: node._meta.title,
type: node.class_type
}
])
)
}
/**
@@ -189,11 +166,21 @@ export const useExecutionStore = defineStore('execution', () => {
() => new Set(executingNodeIds.value.map(String))
)
// For backward compatibility - returns the primary executing node info
const executingNode = computed<ExecutionNodeInfo | null>(() => {
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
return activeJob.value?.nodeLookup?.[String(executingNodeId.value)] ?? null
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
if (!workflow) return null
const canvasState: ComfyWorkflowJSON | null =
workflow.changeTracker?.activeState ?? null
if (!canvasState) return null
return (
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
)
})
// This is the progress of the currently executing node (for backward compatibility)
@@ -549,12 +536,10 @@ export const useExecutionStore = defineStore('execution', () => {
function storeJob({
nodes,
id,
promptOutput,
workflow
}: {
nodes: string[]
id: string
promptOutput: ComfyApiWorkflow
workflow: ComfyWorkflow
}) {
queuedJobs.value[id] ??= { nodes: {} }
@@ -566,7 +551,6 @@ export const useExecutionStore = defineStore('execution', () => {
}, {}),
...queuedJob.nodes
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {