mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
2 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cfb4a1f6 | ||
|
|
fb586063ab |
135
src/renderer/extensions/vueNodes/components/InputSlot.stories.ts
Normal file
135
src/renderer/extensions/vueNodes/components/InputSlot.stories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
function createSlotData(overrides: Partial<INodeSlot> = {}): INodeSlot {
|
||||
return {
|
||||
name: 'image',
|
||||
type: 'IMAGE',
|
||||
boundingRect: [0, 0, 0, 0] as const,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<typeof InputSlot> = {
|
||||
title: 'VueNodes/InputSlot',
|
||||
component: InputSlot,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-48 bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'image' }),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLocalizedName: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: 'image',
|
||||
localized_name: 'Imagen'
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: 'image',
|
||||
label: 'Custom Label'
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'model' }),
|
||||
index: 0,
|
||||
connected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Compatible: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'model' }),
|
||||
index: 0,
|
||||
compatible: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'image' }),
|
||||
index: 0,
|
||||
hasError: true
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyProp: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'image' }),
|
||||
index: 0,
|
||||
dotOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyDerived: Story = {
|
||||
name: 'Dot Only (empty name - reroute)',
|
||||
args: {
|
||||
slotData: createSlotData({ name: '' }),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyNoLocalizedName: Story = {
|
||||
name: 'Dot Only (empty name, no localized_name)',
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: '',
|
||||
localized_name: undefined
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const DifferentTypes: Story = {
|
||||
render: () => ({
|
||||
components: { InputSlot },
|
||||
setup() {
|
||||
const slots = [
|
||||
createSlotData({ name: 'model', type: 'MODEL' }),
|
||||
createSlotData({ name: 'clip', type: 'CLIP' }),
|
||||
createSlotData({ name: 'vae', type: 'VAE' }),
|
||||
createSlotData({ name: 'latent', type: 'LATENT' }),
|
||||
createSlotData({ name: 'anything', type: '*' })
|
||||
]
|
||||
return { slots }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-1">
|
||||
<InputSlot
|
||||
v-for="(slot, i) in slots"
|
||||
:key="i"
|
||||
:slot-data="slot"
|
||||
:index="i"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
function createSlotData(overrides: Partial<INodeSlot> = {}): INodeSlot {
|
||||
return {
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
boundingRect: [0, 0, 0, 0] as const,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<typeof OutputSlot> = {
|
||||
title: 'VueNodes/OutputSlot',
|
||||
component: OutputSlot,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-48 bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'IMAGE' }),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLocalizedName: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: 'IMAGE',
|
||||
localized_name: 'Imagen'
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: 'IMAGE',
|
||||
label: 'Custom Output'
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'IMAGE' }),
|
||||
index: 0,
|
||||
connected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Compatible: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'IMAGE' }),
|
||||
index: 0,
|
||||
compatible: true
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyProp: Story = {
|
||||
args: {
|
||||
slotData: createSlotData({ name: 'IMAGE' }),
|
||||
index: 0,
|
||||
dotOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyDerived: Story = {
|
||||
name: 'Dot Only (empty name - reroute)',
|
||||
args: {
|
||||
slotData: createSlotData({ name: '' }),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const DotOnlyNoLocalizedName: Story = {
|
||||
name: 'Dot Only (empty name, no localized_name)',
|
||||
args: {
|
||||
slotData: createSlotData({
|
||||
name: '',
|
||||
localized_name: undefined
|
||||
}),
|
||||
index: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const DifferentTypes: Story = {
|
||||
render: () => ({
|
||||
components: { OutputSlot },
|
||||
setup() {
|
||||
const slots = [
|
||||
createSlotData({ name: 'IMAGE', type: 'IMAGE' }),
|
||||
createSlotData({ name: 'MODEL', type: 'MODEL' }),
|
||||
createSlotData({ name: 'CLIP', type: 'CLIP' }),
|
||||
createSlotData({ name: 'VAE', type: 'VAE' }),
|
||||
createSlotData({ name: 'LATENT', type: 'LATENT' }),
|
||||
createSlotData({ name: '*', type: '*' })
|
||||
]
|
||||
return { slots }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-1">
|
||||
<OutputSlot
|
||||
v-for="(slot, i) in slots"
|
||||
:key="i"
|
||||
:slot-data="slot"
|
||||
:index="i"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { ChangeTracker } from './changeTracker'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {},
|
||||
rootGraph: {
|
||||
extra: {},
|
||||
nodes: [{ id: 1 }],
|
||||
serialize: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => ({ read_only: false })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => ({
|
||||
show: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
resolveNode: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
function createWorkflowJSON(
|
||||
nodeIds: number[],
|
||||
extra: Record<string, unknown> = {}
|
||||
): ComfyWorkflowJSON {
|
||||
return {
|
||||
last_node_id: nodeIds.length,
|
||||
last_link_id: 0,
|
||||
nodes: nodeIds.map((id) => ({
|
||||
id,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: id,
|
||||
mode: 0,
|
||||
outputs: [],
|
||||
inputs: [],
|
||||
properties: {}
|
||||
})),
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra
|
||||
} as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
function createLoadedWorkflow(
|
||||
path: string,
|
||||
state: ComfyWorkflowJSON
|
||||
): LoadedComfyWorkflow {
|
||||
const workflow = new ComfyWorkflow({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: 100
|
||||
})
|
||||
workflow.changeTracker = new ChangeTracker(workflow, state)
|
||||
workflow.content = JSON.stringify(state)
|
||||
workflow.originalContent = JSON.stringify(state)
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
describe('ChangeTracker.isLoadingGraph guard', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
workflowStore = useWorkflowStore()
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
describe('checkState short-circuit', () => {
|
||||
it('skips checkState when isLoadingGraph is true', () => {
|
||||
const workflowA = createWorkflowJSON([1])
|
||||
const workflowB = createWorkflowJSON([2, 3])
|
||||
|
||||
const workflow = createLoadedWorkflow('workflows/test.json', workflowA)
|
||||
const tracker = workflow.changeTracker
|
||||
|
||||
// Simulate rootGraph containing a different workflow's data
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(
|
||||
workflowB as unknown as ISerialisedGraph
|
||||
)
|
||||
|
||||
// With guard enabled, checkState should not capture the wrong state
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.activeState).toEqual(workflowA)
|
||||
expect(tracker.undoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('captures state when isLoadingGraph is false', () => {
|
||||
const workflowA = createWorkflowJSON([1])
|
||||
const workflowB = createWorkflowJSON([2, 3])
|
||||
|
||||
const workflow = createLoadedWorkflow('workflows/test.json', workflowA)
|
||||
const tracker = workflow.changeTracker
|
||||
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(
|
||||
workflowB as unknown as ISerialisedGraph
|
||||
)
|
||||
|
||||
// Without guard, checkState should capture the new state
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.activeState).toEqual(workflowB)
|
||||
expect(tracker.undoQueue).toHaveLength(1)
|
||||
expect(tracker.undoQueue[0]).toEqual(workflowA)
|
||||
})
|
||||
|
||||
it('prevents cross-workflow corruption during tab switch', () => {
|
||||
const stateA = createWorkflowJSON([1], { name: 'workflowA' })
|
||||
const stateB = createWorkflowJSON([2, 3], { name: 'workflowB' })
|
||||
|
||||
const workflowA = createLoadedWorkflow('workflows/a.json', stateA)
|
||||
|
||||
// Simulate the corruption scenario:
|
||||
// 1. workflowA is the active workflow in the store
|
||||
workflowStore.activeWorkflow = workflowA
|
||||
|
||||
// 2. During loadGraphData, rootGraph is configured with workflowB's data
|
||||
// but activeWorkflow still points to workflowA
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(
|
||||
stateB as unknown as ISerialisedGraph
|
||||
)
|
||||
|
||||
// 3. An extension calls checkState during onAfterGraphConfigured
|
||||
workflowA.changeTracker.checkState()
|
||||
|
||||
// 4. With the guard, workflowA's state should NOT be corrupted
|
||||
expect(workflowA.changeTracker.activeState).toEqual(stateA)
|
||||
expect(workflowA.changeTracker.undoQueue).toHaveLength(0)
|
||||
|
||||
// 5. After loading completes, the guard is lifted
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
it('allows corruption when guard is bypassed (regression confirmation)', () => {
|
||||
const stateA = createWorkflowJSON([1], { name: 'workflowA' })
|
||||
const stateB = createWorkflowJSON([2, 3], { name: 'workflowB' })
|
||||
|
||||
const workflowA = createLoadedWorkflow('workflows/a.json', stateA)
|
||||
|
||||
workflowStore.activeWorkflow = workflowA
|
||||
|
||||
// Without the guard, checkState writes workflowB data into workflowA
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(
|
||||
stateB as unknown as ISerialisedGraph
|
||||
)
|
||||
|
||||
workflowA.changeTracker.checkState()
|
||||
|
||||
// workflowA's activeState is now corrupted with workflowB's data
|
||||
expect(workflowA.changeTracker.activeState).toEqual(stateB)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appModeStore linearData sync guard', () => {
|
||||
it('does not sync linearData when isLoadingGraph is true', async () => {
|
||||
// Dynamically import appModeStore after mocks are set up
|
||||
const { useAppModeStore } = await import('@/stores/appModeStore')
|
||||
const store = useAppModeStore()
|
||||
|
||||
const workflow = createLoadedWorkflow(
|
||||
'workflows/test.json',
|
||||
createWorkflowJSON([1])
|
||||
)
|
||||
workflow.activeMode = 'builder:inputs'
|
||||
workflowStore.activeWorkflow = workflow
|
||||
await nextTick()
|
||||
|
||||
// Set the guard
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
|
||||
// Modify selections — the watcher should skip writing to graph.extra
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toBeUndefined()
|
||||
})
|
||||
|
||||
it('syncs linearData when isLoadingGraph is false', async () => {
|
||||
const { useAppModeStore } = await import('@/stores/appModeStore')
|
||||
const store = useAppModeStore()
|
||||
|
||||
const workflow = createLoadedWorkflow(
|
||||
'workflows/test.json',
|
||||
createWorkflowJSON([1])
|
||||
)
|
||||
workflow.activeMode = 'builder:inputs'
|
||||
workflowStore.activeWorkflow = workflow
|
||||
await nextTick()
|
||||
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
|
||||
store.selectedOutputs.push(1)
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toEqual({
|
||||
inputs: [],
|
||||
outputs: [1]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user