Compare commits

..

1 Commits

Author SHA1 Message Date
MaanilVerma
ff893d2408 [chore] Update Ingest API types from cloud@fbcf68e 2026-06-27 05:17:50 +00:00
32 changed files with 11630 additions and 15955 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
import { defineComponent, h, markRaw, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
@@ -12,35 +12,19 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { toNodeId } from '@/types/nodeId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
const mockApp = vi.hoisted(() => ({
canvas: null
}))
const mockFeatureFlags = vi.hoisted(() => ({
refs: null as null | {
shouldRenderVueNodes: { value: boolean }
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/useVueFeatureFlags', async () => {
const { ref } = await import('vue')
const shouldRenderVueNodes = ref(false)
mockFeatureFlags.refs = {
shouldRenderVueNodes
}
return {
useVueFeatureFlags: () => ({
shouldRenderVueNodes
})
}
})
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: { value: false }
})
}))
describe('useSelectionToolboxPosition', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
@@ -48,39 +32,28 @@ describe('useSelectionToolboxPosition', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
layoutStore.initializeFromLiteGraph([])
layoutStore.isDraggingVueNodes.value = false
if (mockFeatureFlags.refs) {
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
}
})
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true,
...state
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
let toolbox: HTMLElement | undefined
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
toolbox = toolboxRef.value
;({ visible } = useSelectionToolboxPosition(toolboxRef))
useSelectionToolboxPosition(toolboxRef)
return () => h('div')
}
})
@@ -88,28 +61,7 @@ describe('useSelectionToolboxPosition', () => {
const wrapper = render(TestHarness)
if (!toolbox) throw new Error('Toolbox element was not initialized')
if (!visible) throw new Error('Visible state was not initialized')
return { toolbox, unmount: wrapper.unmount, visible }
}
function setCanvasSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {}
) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
state: {
draggingItems: false,
selectionChanged: true,
...state
}
} as Partial<LGraphCanvas> as LGraphCanvas)
return { toolbox, unmount: wrapper.unmount }
}
it('positions groups from their unchanged bounds', () => {
@@ -117,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -129,223 +81,11 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, visible, unmount } = renderToolboxForSelection([])
expect(visible.value).toBe(false)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not update when selection state is unchanged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
selectionChanged: false
})
expect(visible.value).toBe(false)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(visible.value).toBe(false)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(12)
node.pos = [100, 200]
node.size = [160, 80]
layoutStore.initializeFromLiteGraph([
{
id: node.id,
pos: [300, 400],
size: [200, 120]
}
])
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(13)
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('hides the toolbox while Vue nodes are being dragged', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
layoutStore.isDraggingVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items that are not nodes or groups', () => {
const item = createMockPositionable({
id: toNodeId(52),
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
})
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items without valid ids', () => {
const item = {
id: null,
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
} as unknown as Positionable
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('stays visible without mutating style when the toolbox ref is empty', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
setCanvasSelection([group])
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
;({ visible } = useSelectionToolboxPosition(ref()))
return () => h('div')
}
})
const wrapper = render(TestHarness)
expect(visible.value).toBe(true)
wrapper.unmount()
})
it('hides and restores around Vue node drag state changes', async () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0)
)
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
clearTimeout(handle)
})
const { visible, unmount } = renderToolboxForSelection([group])
expect(visible.value).toBe(true)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
expect(visible.value).toBe(false)
layoutStore.isDraggingVueNodes.value = false
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(visible.value).toBe(true)
unmount()
})
})

View File

@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => actions
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -4,45 +4,34 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[],
getSelectedNodes: vi.fn((): unknown[] => []),
getCanvas: vi.fn(),
updateSelectedItems: vi.fn(),
revokeSubgraphPreviews: vi.fn(),
activeWorkflow: null as null | {
changeTracker?: {
captureCanvasState: () => void
}
}
selectedItems: [] as unknown[]
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: mocks.getSelectedNodes
getSelectedNodes: vi.fn(() => [])
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas,
getCanvas: vi.fn(),
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: mocks.updateSelectedItems
updateSelectedItems: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mocks.activeWorkflow
}
activeWorkflow: null
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
revokeSubgraphPreviews: vi.fn()
})
}))
@@ -61,36 +50,10 @@ function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
function createCanvas({
graph,
subgraph,
selectedItems = []
}: {
graph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
subgraph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
selectedItems?: unknown[]
} = {}) {
return {
graph,
subgraph,
selectedItems: new Set(selectedItems),
select: vi.fn()
}
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
mocks.getSelectedNodes.mockReturnValue([])
mocks.getCanvas.mockReturnValue(createCanvas())
mocks.activeWorkflow = null
})
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
@@ -140,126 +103,4 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('reports selected subgraph and selectable node state', async () => {
mocks.selectedItems = [createRegularNode()]
mocks.getSelectedNodes.mockReturnValue([])
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
expect(isSubgraphSelected()).toBe(false)
expect(hasSelectableNodes()).toBe(false)
mocks.selectedItems = [createSubgraphNode()]
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
expect(isSubgraphSelected()).toBe(true)
expect(hasSelectableNodes()).toBe(true)
})
it('converts selected items to a subgraph and captures workflow state', async () => {
const captureCanvasState = vi.fn()
const node = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(() => ({ node })),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({
graph,
selectedItems: [createRegularNode()]
})
mocks.getCanvas.mockReturnValue(canvas)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
convertToSubgraph()
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
expect(canvas.select).toHaveBeenCalledWith(node)
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not select or capture when conversion has no graph or no result', async () => {
const graph = {
convertToSubgraph: vi.fn(() => null),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({ graph })
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(canvas)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
expect(convertToSubgraph()).toBeNull()
expect(convertToSubgraph()).toBeUndefined()
expect(canvas.select).not.toHaveBeenCalled()
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
})
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
const captureCanvasState = vi.fn()
const subgraphNode = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas.mockReturnValue(
createCanvas({
subgraph: graph,
selectedItems: [subgraphNode, createRegularNode()]
})
)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
skipMissingNodes: true
})
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(
createCanvas({ graph, selectedItems: [createRegularNode()] })
)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
unpackSubgraph()
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { ResultItem } from '@/schemas/apiSchema'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
@@ -11,41 +11,22 @@ const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
}))
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
let capturedResultItemDrop: (item: ResultItem) => void
let capturedPasteOnPaste: (files: File[]) => Promise<string[]>
let capturedFileInputOnSelect: (files: File[]) => Promise<string[]>
const mockOpenFileSelection = vi.fn()
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
opts: {
onDrop: typeof capturedDragOnDrop
onResultItemDrop: typeof capturedResultItemDrop
}
opts: { onDrop: typeof capturedDragOnDrop }
) => {
capturedDragOnDrop = opts.onDrop
capturedResultItemDrop = opts.onResultItemDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: (
_node: LGraphNode,
opts: { onSelect: typeof capturedFileInputOnSelect }
) => {
capturedFileInputOnSelect = opts.onSelect
return { openFileSelection: mockOpenFileSelection }
}
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: (
_node: LGraphNode,
opts: { onPaste: typeof capturedPasteOnPaste }
) => {
capturedPasteOnPaste = opts.onPaste
}
useNodePaste: vi.fn()
}))
vi.mock('@/i18n', () => ({
@@ -97,26 +78,6 @@ describe('useNodeImageUpload', () => {
let onUploadStart: (files: File[]) => void
let onUploadError: () => void
async function mountImageUpload(
options: { folder?: ResultItemType } = { folder: 'input' }
) {
const { useNodeImageUpload } = await import('./useNodeImageUpload')
return useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
...options
})
}
function lastUploadBody() {
const body = mockFetchApi.mock.calls.at(-1)?.[1]?.body
if (!(body instanceof FormData)) {
throw new Error('Expected upload body to be FormData')
}
return body
}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
@@ -125,7 +86,13 @@ describe('useNodeImageUpload', () => {
onUploadStart = vi.fn()
onUploadError = vi.fn()
await mountImageUpload()
const { useNodeImageUpload } = await import('./useNodeImageUpload')
useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
folder: 'input'
})
})
it.for([
@@ -213,60 +180,4 @@ describe('useNodeImageUpload', () => {
await capturedDragOnDrop([createFile()])
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
})
it('passes dropped result items through without uploading', () => {
const resultItem = fromAny<ResultItem, unknown>({
filename: 'existing.png',
subfolder: '',
type: 'input'
})
capturedResultItemDrop(resultItem)
expect(onUploadComplete).toHaveBeenCalledWith([resultItem])
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('uploads pasted images to the pasted subfolder', async () => {
const { handleUpload } = await mountImageUpload({})
mockFetchApi.mockResolvedValueOnce(successResponse('image.png'))
await handleUpload(createFile('image.png'))
const body = lastUploadBody()
expect(body.get('subfolder')).toBe('pasted')
expect(body.get('type')).toBeNull()
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('refreshes input assets for default non-pasted uploads', async () => {
const { handleUpload } = await mountImageUpload({})
mockFetchApi.mockResolvedValueOnce(successResponse('upload.png'))
await handleUpload(createFile('upload.png'))
const body = lastUploadBody()
expect(body.get('subfolder')).toBeNull()
expect(body.get('type')).toBeNull()
expect(mockUpdateInputs).toHaveBeenCalledOnce()
})
it('does not refresh input assets for explicit output uploads', async () => {
await mountImageUpload({ folder: 'output' })
mockFetchApi.mockResolvedValueOnce(successResponse('output.png'))
await capturedFileInputOnSelect([createFile('output.png')])
const body = lastUploadBody()
expect(body.get('type')).toBe('output')
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('shows a specific alert for upload timeouts', async () => {
mockFetchApi.mockRejectedValueOnce(new DOMException('', 'TimeoutError'))
await capturedPasteOnPaste([createFile()])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadTimedOut')
})
})

View File

@@ -1,15 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
@@ -30,12 +27,10 @@ vi.mock('@vueuse/core', () => ({
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(function StrokeProcessor() {
return {
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}
})
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -47,15 +42,14 @@ vi.mock('@/platform/updates/common/toastStore', () => {
return { useToastStore: () => store }
})
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeImageUrls: vi.fn(() => undefined as string[] | undefined),
nodeOutputs: {},
nodePreviewImages: {}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
@@ -67,7 +61,7 @@ vi.mock('@/scripts/api', () => ({
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn((): LGraphNode | null => null)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
@@ -99,6 +93,9 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(
nodeId: NodeId = toNodeId('test-node'),
initialModelValue = ''
@@ -122,140 +119,11 @@ function mountPainter(
}
})
const rendered = render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue, unmount: rendered.unmount }
}
function createCanvasContext() {
const gradient = { addColorStop: vi.fn() }
return {
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
clearRect: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
globalCompositeOperation: '',
globalAlpha: 1,
lineWidth: 1,
lineCap: 'butt',
lineJoin: 'miter'
} as unknown as CanvasRenderingContext2D
}
function createCanvasElement(
ctx: CanvasRenderingContext2D,
width = 100,
height = 100
) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
vi.spyOn(canvas, 'getContext').mockReturnValue(fromAny(ctx))
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width,
height,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: vi.fn()
})
vi.spyOn(canvas, 'toBlob').mockImplementation((cb) => cb(new Blob(['x'])))
return canvas
}
async function mountPainterWithMaskCanvas({
modelValue = '',
toBlob = (cb: BlobCallback) => cb(new Blob(['x']))
}: {
modelValue?: string
toBlob?: (cb: BlobCallback) => void
} = {}) {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
getContext: vi.fn(() => ({ clearRect: vi.fn() })),
toBlob
} as unknown as HTMLCanvasElement
const mounted = mountPainter(toNodeId('test-node'), modelValue)
mounted.canvasEl.value = fakeCanvas
await nextTick()
return { maskWidget, ...mounted }
}
function stubFakeImage() {
const images: Array<{
onload: (() => void) | null
onerror: (() => void) | null
}> = []
class FakeImage {
crossOrigin = ''
naturalWidth = 64
naturalHeight = 32
onload: (() => void) | null = null
onerror: (() => void) | null = null
src = ''
constructor() {
images.push(this)
}
}
vi.stubGlobal('Image', FakeImage)
return images
}
function createPointerEvent(
type: string,
values: {
clientX?: number
clientY?: number
offsetX?: number
offsetY?: number
button?: number
pointerId?: number
target?: Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>
} = {}
) {
const event = new PointerEvent(type, {
button: values.button ?? 0,
clientX: values.clientX ?? 0,
clientY: values.clientY ?? 0,
pointerId: values.pointerId ?? 1
})
Object.defineProperty(event, 'offsetX', { value: values.offsetX ?? 0 })
Object.defineProperty(event, 'offsetY', { value: values.offsetY ?? 0 })
Object.defineProperty(event, 'target', {
value:
values.target ??
({
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
} as Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>)
})
return event
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
@@ -265,7 +133,6 @@ describe('usePainter', () => {
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue(undefined)
})
describe('syncCanvasSizeFromWidgets', () => {
@@ -284,25 +151,6 @@ describe('usePainter', () => {
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
it('keeps defaults when the node id is empty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { painter } = mountPainter(toNodeId(''))
expect(app.canvas.graph!.getNodeById).not.toHaveBeenCalled()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
expect(painter.inputImageUrl.value).toBeNull()
expect(painter.isImageInputConnected.value).toBe(false)
expect(maskWidget.serializeValue).toBeUndefined()
painter.brushSize.value = 36
await nextTick()
expect(mockProperties.painterBrushSize).toBeUndefined()
})
})
describe('restoreSettingsFromProperties', () => {
@@ -378,18 +226,6 @@ describe('usePainter', () => {
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
it('skips widget callbacks when dimensions are unchanged', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
mountPainter()
await nextTick()
expect(widthWidget.callback).not.toHaveBeenCalled()
expect(heightWidget.callback).not.toHaveBeenCalled()
})
})
describe('syncBackgroundColorToWidget', () => {
@@ -405,16 +241,6 @@ describe('usePainter', () => {
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
it('skips widget callbacks when the background color is unchanged', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
mountPainter()
await nextTick()
expect(bgWidget.callback).not.toHaveBeenCalled()
})
})
describe('updateInputImageUrl', () => {
@@ -432,34 +258,6 @@ describe('usePainter', () => {
expect(painter.isImageInputConnected.value).toBe(true)
})
it('sets inputImageUrl from the connected input node output', () => {
const inputNode = {} as LGraphNode
mockIsInputConnected.mockReturnValue(true)
mockGetInputNode.mockReturnValue(inputNode)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([
'http://localhost:8188/view?filename=input.png'
])
const { painter } = mountPainter()
expect(mockNodeOutputStore.getNodeImageUrls).toHaveBeenCalledWith(
inputNode
)
expect(painter.inputImageUrl.value).toBe(
'http://localhost:8188/view?filename=input.png'
)
})
it('keeps inputImageUrl null when a connected input has no images', () => {
mockIsInputConnected.mockReturnValue(true)
mockGetInputNode.mockReturnValue({} as LGraphNode)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([])
const { painter } = mountPainter()
expect(painter.inputImageUrl.value).toBeNull()
})
})
describe('handleInputImageLoad', () => {
@@ -484,20 +282,6 @@ describe('usePainter', () => {
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
it('updates canvas size when dimension widgets are absent', () => {
const { painter } = mountPainter()
painter.handleInputImageLoad({
target: {
naturalWidth: 320,
naturalHeight: 240
}
} as unknown as Event)
expect(painter.canvasWidth.value).toBe(320)
expect(painter.canvasHeight.value).toBe(240)
})
})
describe('cursor visibility', () => {
@@ -515,17 +299,6 @@ describe('usePainter', () => {
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
it('positions the custom cursor on pointer movement', () => {
const { painter, cursorEl } = mountPainter()
cursorEl.value = document.createElement('div')
painter.handlePointerMove(
createPointerEvent('pointermove', { offsetX: 25, offsetY: 30 })
)
expect(cursorEl.value.style.transform).toBe('translate(15px, 20px)')
})
})
describe('displayBrushSize', () => {
@@ -592,13 +365,24 @@ describe('usePainter', () => {
})
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(fetchApiMock).toHaveBeenCalledWith(
@@ -615,81 +399,33 @@ describe('usePainter', () => {
})
it('throws when the upload response is missing a name', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => ({})
} as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/missing 'name'/)
})
it('throws when the upload request fails', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('offline'))
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('reports non-error upload rejections', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce('offline')
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/offline/)
})
it('throws when the upload response is not successful', async () => {
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 500,
statusText: 'Internal Server Error',
text: async () => 'upload failed'
} as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/upload failed/)
})
it('uses statusText when an unsuccessful upload response has no body', async () => {
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 502,
statusText: 'Bad Gateway',
text: async () => ''
} as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/Bad Gateway/)
})
it('uses unknown error when an unsuccessful upload response has no detail', async () => {
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 500,
statusText: '',
text: async () => ''
} as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/unknown error/)
})
it('throws when the upload response body is not valid JSON', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
@@ -697,62 +433,21 @@ describe('usePainter', () => {
}
} as unknown as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('reports non-error JSON parse failures', async () => {
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
throw 'bad json'
}
} as unknown as Response)
const { maskWidget } = await mountPainterWithMaskCanvas()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/bad json/)
})
it('returns modelValue when dirty canvas serialization produces no blob', async () => {
const { painter, maskWidget } = await mountPainterWithMaskCanvas({
toBlob: (cb) => cb(null)
})
painter.handleClear()
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('returns existing modelValue when canvas serialization produces no blob', async () => {
const toBlob = vi.fn((cb: BlobCallback) => cb(null))
const { painter, maskWidget, modelValue } =
await mountPainterWithMaskCanvas({
modelValue: 'painter/cached.png [temp]',
toBlob
})
// handleClear marks the canvas dirty; restore the cached value it wipes
painter.handleClear()
modelValue.value = 'painter/cached.png [temp]'
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(toBlob).toHaveBeenCalled()
expect(result).toBe('painter/cached.png [temp]')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
@@ -803,83 +498,6 @@ describe('usePainter', () => {
expect.stringContaining('type=temp')
)
})
it('defaults restored mask type to input when no type suffix exists', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter(toNodeId('test-node'), 'plain.png')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=plain.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=input')
)
expect(api.apiURL).not.toHaveBeenCalledWith(
expect.stringContaining('subfolder=')
)
})
it('does not restore a canvas when the mask value is blank', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter(toNodeId('test-node'), ' ')
expect(api.apiURL).not.toHaveBeenCalled()
})
it('draws a restored mask after the image loads', () => {
const images = stubFakeImage()
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
canvasEl.value = createCanvasElement(ctx)
images[0].onload?.()
expect(painter.canvasWidth.value).toBe(64)
expect(painter.canvasHeight.value).toBe(32)
expect(ctx.drawImage).toHaveBeenCalled()
})
it('ignores restored image loads after the canvas unmounts', () => {
const images = stubFakeImage()
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl, unmount } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
canvasEl.value = createCanvasElement(ctx)
unmount()
// Vue clears template refs on unmount
canvasEl.value = null
images[0].onload?.()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
expect(ctx.drawImage).not.toHaveBeenCalled()
})
it('clears stale modelValue when restored image loading fails', () => {
const images = stubFakeImage()
const { modelValue } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
images[0].onerror?.()
expect(modelValue.value).toBe('')
})
})
describe('handleClear', () => {
@@ -888,36 +506,6 @@ describe('usePainter', () => {
expect(() => painter.handleClear()).not.toThrow()
})
it('clears the canvas and marks the current mask dirty', async () => {
const maskWidget = makeWidget('mask', '')
const ctx = createCanvasContext()
mockWidgets.push(maskWidget)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl, modelValue } = mountPainter(
toNodeId('test-node'),
'painter/cached.png [temp]'
)
canvasEl.value = createCanvasElement(ctx, 50, 40)
painter.handleClear()
await nextTick()
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 50, 40)
expect(modelValue.value).toBe('')
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'cleared.png' })
} as Response)
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).resolves.toBe('cleared.png [input]')
})
})
describe('handlePointerDown', () => {
@@ -959,184 +547,6 @@ describe('usePainter', () => {
expect(() => painter.handlePointerDown(event)).not.toThrow()
})
it('draws a hard brush stroke across pointer events', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', {
clientX: 60,
clientY: 10,
offsetX: 60,
offsetY: 10
})
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.arc).toHaveBeenCalled()
expect(ctx.moveTo).toHaveBeenCalled()
expect(ctx.lineTo).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.drawImage).toHaveBeenCalled()
})
it('draws a soft brush stroke with radial dabs', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.brushHardness.value = 0.5
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 70, clientY: 10 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.createRadialGradient).toHaveBeenCalled()
expect(ctx.arc).toHaveBeenCalled()
expect(ctx.drawImage).toHaveBeenCalled()
})
it('uses destination-out composition for eraser strokes', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.tool.value = 'eraser'
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
expect(ctx.globalCompositeOperation).toBe('destination-out')
})
it('does not start drawing when a canvas context is unavailable', () => {
const canvas = document.createElement('canvas')
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: vi.fn()
})
const { painter, canvasEl } = mountPainter()
canvasEl.value = canvas
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
expect(() => {
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
}).not.toThrow()
expect(canvas.getContext).toHaveBeenCalledWith('2d')
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('uses one animation frame for pending pointer movement', () => {
const ctx = createCanvasContext()
let frameCallback: FrameRequestCallback | undefined
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback) => {
frameCallback = callback
return 7
}
)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 30, clientY: 30 })
)
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
frameCallback?.(0)
expect(ctx.lineTo).toHaveBeenCalled()
})
it('flushes a pending pointer movement when leaving the canvas', () => {
const ctx = createCanvasContext()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerLeave()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
expect(ctx.lineTo).toHaveBeenCalled()
})
it('cancels a pending pointer movement when unmounted', () => {
const ctx = createCanvasContext()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl, unmount } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
unmount()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
})
})
describe('handlePointerUp', () => {
@@ -1171,32 +581,5 @@ describe('usePainter', () => {
expect(() => painter.handlePointerUp(event)).not.toThrow()
})
it('draws final stroke processor points on release', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
vi.mocked(StrokeProcessor).mockImplementationOnce(
class MockStrokeProcessor {
addPoint = vi.fn(() => [])
endStroke = vi.fn(() => [
{ x: 40, y: 10 },
{ x: 80, y: 10 }
])
} as unknown as typeof StrokeProcessor
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.moveTo).toHaveBeenCalledWith(10, 10)
expect(ctx.lineTo).toHaveBeenCalledWith(40, 10)
expect(ctx.lineTo).toHaveBeenCalledWith(80, 10)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,24 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
type MediaControls = {
playing: Ref<boolean>
currentTime: Ref<number>
duration: Ref<number>
volume: Ref<number>
muted: Ref<boolean>
}
const mockMediaControls = vi.hoisted(() => ({
values: [] as MediaControls[]
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
useMediaControls: () =>
mockMediaControls.values.shift() ?? {
playing: ref(false),
currentTime: ref(0),
duration: ref(0),
volume: ref(1),
muted: ref(false)
}
useMediaControls: () => ({
playing: ref(false),
currentTime: ref(0),
duration: ref(0)
})
}
})
const mockFetchApi = vi.fn()
const originalAudioContext = globalThis.AudioContext
function queueMediaControls(overrides: Partial<MediaControls> = {}) {
const controls: MediaControls = {
playing: ref(false),
currentTime: ref(0),
duration: ref(0),
volume: ref(1),
muted: ref(false),
...overrides
}
mockMediaControls.values.push(controls)
return controls
}
beforeEach(() => {
mockMediaControls.values = []
})
afterEach(() => {
globalThis.AudioContext = originalAudioContext
mockFetchApi.mockReset()
@@ -83,21 +50,6 @@ describe('useWaveAudioPlayer', () => {
expect(playedBarIndex.value).toBe(-1)
})
it('computes progress and played bar when duration is known', () => {
queueMediaControls({
currentTime: ref(30),
duration: ref(120)
})
const src = ref('')
const { playedBarIndex, progressRatio } = useWaveAudioPlayer({
src,
barCount: 40
})
expect(playedBarIndex.value).toBe(9)
expect(progressRatio.value).toBe(25)
})
it('generates bars with heights between 10 and 70', () => {
const src = ref('')
const { bars } = useWaveAudioPlayer({ src })
@@ -113,56 +65,6 @@ describe('useWaveAudioPlayer', () => {
expect(isPlaying.value).toBe(false)
})
it('updates playback and seek controls', () => {
const controls = queueMediaControls({
currentTime: ref(10),
duration: ref(100)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.togglePlayPause()
expect(player.isPlaying.value).toBe(true)
player.seekToStart()
expect(controls.currentTime.value).toBe(0)
player.seekToRatio(0.25)
expect(controls.currentTime.value).toBe(25)
player.seekToRatio(-1)
expect(controls.currentTime.value).toBe(0)
player.seekToRatio(2)
expect(controls.currentTime.value).toBe(100)
player.seekToEnd()
expect(controls.currentTime.value).toBe(100)
expect(player.isPlaying.value).toBe(false)
})
it('updates mute state and volume icon', () => {
const controls = queueMediaControls({
volume: ref(1),
muted: ref(false)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-2]')
controls.volume.value = 0.25
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-1]')
controls.volume.value = 0
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
controls.volume.value = 1
player.toggleMute()
expect(controls.muted.value).toBe(true)
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
})
it('shows 0:00 for formatted times initially', () => {
const src = ref('')
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
@@ -206,91 +108,6 @@ describe('useWaveAudioPlayer', () => {
expect(bars.value).toHaveLength(10)
})
it('uses placeholder bars when decoded audio has no channel data', async () => {
const mockAudioBuffer = {
getChannelData: vi.fn(() => new Float32Array())
}
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
const mockClose = vi.fn().mockResolvedValue(undefined)
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
class {
decodeAudioData = mockDecodeAudioData
close = mockClose
}
)
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))
})
const src = ref('/api/view?filename=empty.wav&type=output')
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 6 })
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(bars.value).toHaveLength(6)
for (const bar of bars.value) {
expect(bar.height).toBeGreaterThanOrEqual(10)
expect(bar.height).toBeLessThanOrEqual(70)
}
})
it('uses placeholder bars when fetching audio fails', async () => {
mockFetchApi.mockResolvedValue({
ok: false,
status: 500
})
const src = ref('https://example.com/audio.wav')
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 5 })
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(mockFetchApi).toHaveBeenCalledWith('https://example.com/audio.wav')
expect(bars.value).toHaveLength(5)
})
it('seeks from waveform clicks and starts playback', () => {
const controls = queueMediaControls({
duration: ref(100)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 50 }))
expect(controls.currentTime.value).toBe(0)
player.waveformRef.value = fromPartial<HTMLElement>({
getBoundingClientRect: () => ({ left: 10, width: 100 })
})
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 60 }))
expect(controls.currentTime.value).toBe(50)
expect(player.isPlaying.value).toBe(true)
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: -100 }))
expect(controls.currentTime.value).toBe(0)
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 999 }))
expect(controls.currentTime.value).toBe(100)
})
it('ignores waveform clicks when duration is zero', () => {
const controls = queueMediaControls()
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.waveformRef.value = fromPartial<HTMLElement>({
getBoundingClientRect: () => ({ left: 0, width: 100 })
})
player.handleWaveformClick(fromPartial<MouseEvent>({ clientX: 50 }))
expect(controls.currentTime.value).toBe(0)
expect(player.isPlaying.value).toBe(false)
})
it('does not call decodeAudioSource when src is empty', () => {
const src = ref('')
useWaveAudioPlayer({ src })

View File

@@ -3605,7 +3605,6 @@
"back": "Back",
"next": "Next",
"publishButton": "Publish to ComfyHub",
"updateButton": "Update workflow",
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
@@ -3620,8 +3619,6 @@
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.",
"renameFailedTitle": "Rename failed",
"renameFailedDescription": "Your workflow was published successfully, but renaming the local file failed. Rename it again to match.",
"publishSuccessTitle": "Published successfully",
"publishSuccessDescription": "Your workflow is now live on ComfyHub."
},
@@ -3630,12 +3627,9 @@
"profileCreationNav": "Profile creation",
"introTitle": "Publish to the ComfyHub",
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
"updateIntroTitle": "Update your ComfyHub workflow",
"updateIntroDescription": "Push your latest changes to ComfyHub. Your share link and stats stay the same.",
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
"createProfileButton": "Create my profile",
"startPublishingButton": "Start publishing",
"startUpdatingButton": "Update workflow",
"modalTitle": "Create your profile on ComfyHub",
"createProfileTitle": "Create your Comfy Hub profile",
"uploadCover": "+ Upload a cover",

View File

@@ -176,7 +176,6 @@
<ComfyHubPublishIntroPanel
v-else
data-testid="publish-intro"
:is-update="!!publishResult"
:on-create-profile="handleOpenPublishDialog"
:on-close="onClose"
:show-close-button="false"

View File

@@ -15,18 +15,10 @@
<!-- Content -->
<section class="flex flex-col items-center gap-4 px-4 pt-4 pb-6">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{
isUpdate
? $t('comfyHubProfile.updateIntroTitle')
: $t('comfyHubProfile.introTitle')
}}
{{ $t('comfyHubProfile.introTitle') }}
</h2>
<p class="m-0 text-center text-sm text-muted-foreground">
{{
isUpdate
? $t('comfyHubProfile.updateIntroDescription')
: $t('comfyHubProfile.introDescription')
}}
{{ $t('comfyHubProfile.introDescription') }}
</p>
<Button
variant="primary"
@@ -34,11 +26,7 @@
class="mt-2 w-full"
@click="onCreateProfile"
>
{{
isUpdate
? $t('comfyHubProfile.startUpdatingButton')
: $t('comfyHubProfile.startPublishingButton')
}}
{{ $t('comfyHubProfile.startPublishingButton') }}
</Button>
</section>
</div>
@@ -50,12 +38,10 @@ import Button from '@/components/ui/button/Button.vue'
const {
onCreateProfile,
onClose,
showCloseButton = true,
isUpdate = false
showCloseButton = true
} = defineProps<{
onCreateProfile: () => void
onClose: () => void
showCloseButton?: boolean
isUpdate?: boolean
}>()
</script>

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
@@ -30,10 +30,6 @@ const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
const mockRenameWorkflow = vi.hoisted(() => vi.fn())
const mockFormDataHolder = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -46,41 +42,35 @@ vi.mock(
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
() => {
mockFormDataHolder.value = {
name: '',
description: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
}
return {
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref(mockFormDataHolder.value),
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
() => ({
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref({
name: '',
description: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
}
}
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
})
)
vi.mock(
@@ -100,44 +90,23 @@ vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: mockRenameWorkflow,
renameWorkflow: vi.fn(),
saveWorkflow: vi.fn()
})
}))
const mockWorkflowStore = vi.hoisted(() => {
return {
instance: null as { activeWorkflow: Record<string, unknown> | null } | null
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { reactive } = await import('vue')
mockWorkflowStore.instance = reactive({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
} as Record<string, unknown> | null
},
saveWorkflow: vi.fn()
})
return {
useWorkflowStore: () => ({
...mockWorkflowStore.instance,
get activeWorkflow() {
return mockWorkflowStore.instance?.activeWorkflow ?? null
},
saveWorkflow: vi.fn()
})
}
})
function setActiveWorkflow(workflow: Record<string, unknown> | null) {
if (mockWorkflowStore.instance) {
mockWorkflowStore.instance.activeWorkflow = workflow
}
}
}))
async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
@@ -148,17 +117,8 @@ describe('ComfyHubPublishDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
setActiveWorkflow({
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockRenameWorkflow.mockResolvedValue(undefined)
if (mockFormDataHolder.value) mockFormDataHolder.value.name = ''
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
@@ -266,119 +226,6 @@ describe('ComfyHubPublishDialog', () => {
expect(onClose).toHaveBeenCalledOnce()
})
it('renames the local workflow when the published name differs', async () => {
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockRenameWorkflow).toHaveBeenCalledWith(
expect.anything(),
'workflows/renamed.json'
)
expect(mockSubmitToComfyHub.mock.invocationCallOrder[0]).toBeLessThan(
mockRenameWorkflow.mock.invocationCallOrder[0]
)
})
it('does not rename when the published name matches the file name', async () => {
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'test'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockRenameWorkflow).not.toHaveBeenCalled()
})
it('still reports success but warns when the post-publish rename fails', async () => {
mockRenameWorkflow.mockRejectedValueOnce(new Error('rename failed'))
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
expect(onClose).toHaveBeenCalledOnce()
})
it('does not rename or close when publish submission fails', async () => {
mockSubmitToComfyHub.mockRejectedValueOnce(new Error('submit failed'))
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(mockRenameWorkflow).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockToastAdd).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
expect(onClose).not.toHaveBeenCalled()
})
it('does not refetch publish status when the rename changes the path mid-publish', async () => {
mockRenameWorkflow.mockImplementationOnce(async () => {
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
})
renderComponent()
await flushPromises()
mockGetPublishStatus.mockClear()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockGetPublishStatus).not.toHaveBeenCalledWith(
'workflows/renamed.json'
)
})
it('caches the prefill under the renamed path after publish', async () => {
mockRenameWorkflow.mockImplementationOnce(async () => {
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
})
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockCachePublishPrefill).toHaveBeenCalledWith(
'workflows/renamed.json',
expect.anything()
)
})
it('applies prefill when workflow is already published with metadata', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
@@ -435,95 +282,4 @@ describe('ComfyHubPublishDialog', () => {
expect(mockApplyPrefill).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('falls back to cached prefill when the status fetch fails', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
const cached = { description: 'cached' }
mockGetCachedPrefill.mockReturnValue(cached)
renderComponent()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith(cached)
})
it('refetches prefill when the active workflow path changes (e.g. rename)', async () => {
renderComponent()
await flushPromises()
expect(mockGetPublishStatus).toHaveBeenLastCalledWith('workflows/test.json')
mockGetPublishStatus.mockClear()
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
await nextTick()
await flushPromises()
expect(mockGetPublishStatus).toHaveBeenCalledWith('workflows/renamed.json')
})
it('does not refetch prefill when the active workflow path is unchanged', async () => {
renderComponent()
await flushPromises()
mockGetPublishStatus.mockClear()
setActiveWorkflow({
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: true
})
await nextTick()
await flushPromises()
expect(mockGetPublishStatus).not.toHaveBeenCalled()
})
it('ignores a stale prefill response after the workflow path changes', async () => {
const stalePrefill = { description: 'stale' }
let resolveStale: (value: unknown) => void = () => {}
mockGetPublishStatus.mockImplementation((path: string) => {
if (path === 'workflows/test.json') {
return new Promise((resolve) => {
resolveStale = resolve
})
}
return Promise.resolve({
isPublished: true,
shareId: 'fresh',
shareUrl: null,
publishedAt: new Date(),
prefill: { description: 'fresh' }
})
})
renderComponent()
await nextTick()
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
await nextTick()
await flushPromises()
resolveStale({
isPublished: true,
shareId: 'stale',
shareUrl: null,
publishedAt: new Date(),
prefill: stalePrefill
})
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalledWith(stalePrefill)
})
})

View File

@@ -60,7 +60,6 @@
:is-first-step
:is-last-step
:is-publishing
:is-update="isAlreadyPublished"
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
@@ -130,7 +129,6 @@ const {
applyPrefill
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
const isAlreadyPublished = ref(false)
const needsSave = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
@@ -207,27 +205,6 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function syncWorkflowName(): Promise<void> {
const workflow = workflowStore.activeWorkflow
if (!workflow || workflow.isTemporary) return
const desiredName = formData.value.name.trim().replace(/\.json$/i, '')
const currentName = workflow.filename.replace(/\.json$/i, '')
if (!desiredName || desiredName === currentName) return
const newPath = buildWorkflowPath(workflow.directory, desiredName)
try {
await workflowService.renameWorkflow(workflow, newPath)
} catch (error) {
console.error('Failed to rename workflow after publish:', error)
toast.add({
severity: 'warn',
summary: t('comfyHubPublish.renameFailedTitle'),
detail: t('comfyHubPublish.renameFailedDescription')
})
}
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
@@ -236,7 +213,6 @@ async function handlePublish(): Promise<void> {
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
await syncWorkflowName()
const path = workflowStore.activeWorkflow?.path
if (path) {
cachePublishPrefill(path, formData.value)
@@ -266,15 +242,10 @@ function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
async function fetchPublishPrefill() {
const path = workflowStore.activeWorkflow?.path
if (!path) {
isAlreadyPublished.value = false
return
}
if (!path) return
try {
const status = await shareService.getPublishStatus(path)
if (workflowStore.activeWorkflow?.path !== path) return
isAlreadyPublished.value = status.isPublished
const prefill = status.isPublished
? (status.prefill ?? getCachedPrefill(path))
: getCachedPrefill(path)
@@ -282,8 +253,6 @@ async function fetchPublishPrefill() {
applyPrefill(prefill)
}
} catch (error) {
if (workflowStore.activeWorkflow?.path !== path) return
isAlreadyPublished.value = false
console.warn('Failed to fetch publish prefill:', error)
const cached = getCachedPrefill(path)
if (cached) {
@@ -298,15 +267,6 @@ onMounted(() => {
void fetchPublishPrefill()
})
watch(
() => workflowStore.activeWorkflow?.path,
(newPath, oldPath) => {
if (isPublishing.value) return
if (!newPath || newPath === oldPath) return
void fetchPublishPrefill()
}
)
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
if (image.file) {

View File

@@ -1,30 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
function renderFooter(props: Record<string, unknown> = {}) {
return render(ComfyHubPublishFooter, {
props: { isFirstStep: false, isLastStep: true, ...props },
global: {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
describe('ComfyHubPublishFooter', () => {
it('shows the publish label for a new workflow', () => {
renderFooter({ isUpdate: false })
expect(screen.getByText('comfyHubPublish.publishButton')).toBeTruthy()
})
it('shows the update label when the workflow is already published', () => {
renderFooter({ isUpdate: true })
expect(screen.getByText('comfyHubPublish.updateButton')).toBeTruthy()
})
})

View File

@@ -23,26 +23,13 @@
:loading="isPublishing"
@click="$emit('publish')"
>
<i
:class="
cn(
'size-4',
isUpdate ? 'icon-[lucide--refresh-cw]' : 'icon-[lucide--upload]'
)
"
/>
{{
isUpdate
? $t('comfyHubPublish.updateButton')
: $t('comfyHubPublish.publishButton')
}}
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</footer>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
@@ -50,7 +37,6 @@ defineProps<{
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
isUpdate?: boolean
}>()
defineEmits<{

View File

@@ -52,11 +52,8 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}

View File

@@ -24,24 +24,14 @@
>
<ComfyHubThumbnailStep
:thumbnail-type="formData.thumbnailType"
:thumbnail-file="formData.thumbnailFile"
:thumbnail-url="formData.thumbnailUrl"
:existing-thumbnail-type="formData.existingThumbnailType"
:comparison-before-file="formData.comparisonBeforeFile"
:comparison-after-file="formData.comparisonAfterFile"
:comparison-after-url="formData.comparisonAfterUrl"
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
@update:thumbnail-url="onUpdateFormData({ thumbnailUrl: $event })"
@update:comparison-before-file="
onUpdateFormData({ comparisonBeforeFile: $event })
"
@update:comparison-after-file="
onUpdateFormData({ comparisonAfterFile: $event })
"
@update:comparison-after-url="
onUpdateFormData({ comparisonAfterUrl: $event })
"
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
@@ -71,7 +61,6 @@
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
:is-update
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
@@ -103,7 +92,6 @@ const {
isFirstStep,
isLastStep,
isPublishing = false,
isUpdate = false,
onGoNext,
onGoBack,
onUpdateFormData,
@@ -117,7 +105,6 @@ const {
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
isUpdate?: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void

View File

@@ -1,248 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
function renderStep(
props: Record<string, unknown> = {},
callbacks: Record<string, ReturnType<typeof vi.fn>> = {}
) {
return render(ComfyHubThumbnailStep, {
props: { thumbnailType: 'image' as ThumbnailType, ...props, ...callbacks },
global: {
mocks: { $t: (key: string) => key },
stubs: {
ToggleGroup: {
template:
'<div><button data-testid="type-image" @click="$emit(\'update:modelValue\', \'image\')" /><button data-testid="type-video" @click="$emit(\'update:modelValue\', \'video\')" /><button data-testid="type-comparison" @click="$emit(\'update:modelValue\', \'imageComparison\')" /><slot /></div>'
},
ToggleGroupItem: { template: '<div><slot /></div>', props: ['value'] },
Button: {
template:
'<button data-testid="clear-button" @click="$emit(\'click\')"><slot /></button>'
}
}
}
})
}
describe('ComfyHubThumbnailStep', () => {
it('shows the existing image thumbnail on the image tab', () => {
renderStep({
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
})
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://cdn.example.com/thumb.png'
)
})
it('does not show an existing image thumbnail on the video tab', () => {
renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
})
// The image must not leak into the video tab as a preview; the upload
// prompt stays visible instead.
expect(screen.queryByRole('img')).toBeNull()
expect(
screen.getByText('comfyHubPublish.uploadPromptClickToBrowse')
).toBeTruthy()
})
it('keeps the existing thumbnail URL when the type changes', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateThumbnailFile = vi.fn()
const onUpdateThumbnailType = vi.fn()
renderStep(
{
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
'onUpdate:thumbnailType': onUpdateThumbnailType
}
)
await user.click(screen.getByTestId('type-video'))
expect(onUpdateThumbnailType).toHaveBeenCalledWith('video')
// The uploaded file is cleared, but the existing URL is preserved so
// toggling back restores the preview.
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
})
it('keeps restored comparison URLs when switching away from the comparison type', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateComparisonAfterUrl = vi.fn()
const onUpdateComparisonBeforeFile = vi.fn()
const onUpdateComparisonAfterFile = vi.fn()
const onUpdateThumbnailType = vi.fn()
renderStep(
{
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile,
'onUpdate:thumbnailType': onUpdateThumbnailType
}
)
await user.click(screen.getByTestId('type-image'))
expect(onUpdateThumbnailType).toHaveBeenCalledWith('image')
// Comparison file inputs reset, but the restored before/after URLs stay
// inert so switching back restores the previews.
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
expect(onUpdateComparisonAfterUrl).not.toHaveBeenCalled()
})
it('renders a restored GIF thumbnail as an image, not a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/anim.gif',
existingThumbnailType: 'video'
})
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://cdn.example.com/anim.gif'
)
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).toBeNull()
})
it('renders a restored mp4 thumbnail as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/clip.mp4',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
const video = container.querySelector('video')
expect(video?.getAttribute('src')).toBe('https://cdn.example.com/clip.mp4')
expect(screen.queryByRole('img')).toBeNull()
})
it('renders a restored extensionless video-mode thumbnail as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/assets/object-key',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).not.toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
it('renders a restored video-mode thumbnail with a query string as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/clip?token=abc123',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).not.toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
it('restores both comparison images on the comparison tab', () => {
const { container } = renderStep({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
const srcs = Array.from(container.querySelectorAll('img')).map((el) =>
el.getAttribute('src')
)
expect(srcs).toContain('https://cdn.example.com/before.png')
expect(srcs).toContain('https://cdn.example.com/after.png')
})
it('clears a restored image thumbnail when removed', async () => {
const user = userEvent.setup()
const onUpdateThumbnailFile = vi.fn()
const onUpdateThumbnailUrl = vi.fn()
renderStep(
{
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
},
{
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl
}
)
await user.click(screen.getByTestId('clear-button'))
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
})
it('clears both restored comparison images when removed', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateComparisonAfterUrl = vi.fn()
const onUpdateComparisonBeforeFile = vi.fn()
const onUpdateComparisonAfterFile = vi.fn()
renderStep(
{
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile
}
)
await user.click(screen.getByTestId('clear-button'))
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterUrl).toHaveBeenCalledWith(null)
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
})
})

View File

@@ -150,7 +150,7 @@
/>
<template v-if="thumbnailPreviewUrl">
<video
v-if="showVideoPreview"
v-if="isVideoFile"
:src="thumbnailPreviewUrl"
:aria-label="$t('comfyHubPublish.videoPreview')"
class="max-h-full max-w-full object-contain"
@@ -194,34 +194,18 @@ import {
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@comfyorg/tailwind-utils'
import { useDropZone, useObjectUrl } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
const {
thumbnailType = 'image',
thumbnailFile = null,
thumbnailUrl = null,
existingThumbnailType = null,
comparisonBeforeFile = null,
comparisonAfterFile = null,
comparisonAfterUrl = null
} = defineProps<{
const { thumbnailType = 'image' } = defineProps<{
thumbnailType?: ThumbnailType
thumbnailFile?: File | null
thumbnailUrl?: string | null
existingThumbnailType?: ThumbnailType | null
comparisonBeforeFile?: File | null
comparisonAfterFile?: File | null
comparisonAfterUrl?: string | null
}>()
const emit = defineEmits<{
'update:thumbnailType': [value: ThumbnailType]
'update:thumbnailFile': [value: File | null]
'update:thumbnailUrl': [value: string | null]
'update:comparisonBeforeFile': [value: File | null]
'update:comparisonAfterFile': [value: File | null]
'update:comparisonAfterUrl': [value: string | null]
}>()
const { t } = useI18n()
@@ -232,7 +216,8 @@ function isThumbnailType(value: string): value is ThumbnailType {
function handleThumbnailTypeChange(value: unknown) {
if (typeof value === 'string' && isThumbnailType(value)) {
// Keep existing URLs; they stay inert until the type matches again (see existingSingleUrl).
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:thumbnailFile', null)
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
@@ -271,59 +256,30 @@ const thumbnailOptions = [
icon: 'icon-[lucide--diff]'
}
]
const existingSingleUrl = computed(() =>
existingThumbnailType === thumbnailType
? (thumbnailUrl ?? undefined)
: undefined
)
// For imageComparison, thumbnailUrl is the "before" image (the backend's primary thumbnail_url).
const existingComparisonUrls = computed(() =>
existingThumbnailType === 'imageComparison'
? {
before: thumbnailUrl ?? undefined,
after: comparisonAfterUrl ?? undefined
}
: { before: undefined, after: undefined }
)
const thumbnailFileUrl = useObjectUrl(() => thumbnailFile ?? undefined)
const thumbnailPreviewUrl = computed(
() => thumbnailFileUrl.value ?? existingSingleUrl.value
)
function isAnimatedImageUrl(url: string): boolean {
return /\.(gif|webp)(\?|#|$)/i.test(url)
}
const showVideoPreview = computed(() => {
if (thumbnailFile) return thumbnailFile.type.startsWith('video/')
return thumbnailType === 'video' && !!existingSingleUrl.value
? !isAnimatedImageUrl(existingSingleUrl.value)
: false
})
const thumbnailFile = shallowRef<File | null>(null)
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
const isVideoFile = ref(false)
function setThumbnailPreview(file: File) {
const maxSize = file.type.startsWith('video/')
? MAX_VIDEO_SIZE_MB
: MAX_IMAGE_SIZE_MB
if (isFileTooLarge(file, maxSize)) return
thumbnailFile.value = file
isVideoFile.value = file.type.startsWith('video/')
emit('update:thumbnailFile', file)
emit('update:thumbnailUrl', null)
}
const comparisonBeforeFileUrl = useObjectUrl(
() => comparisonBeforeFile ?? undefined
)
const comparisonAfterFileUrl = useObjectUrl(
() => comparisonAfterFile ?? undefined
)
const comparisonPreviewUrls = computed(() => ({
before: comparisonBeforeFileUrl.value ?? existingComparisonUrls.value.before,
after: comparisonAfterFileUrl.value ?? existingComparisonUrls.value.after
}))
const comparisonBeforeFile = shallowRef<File | null>(null)
const comparisonAfterFile = shallowRef<File | null>(null)
const comparisonPreviewUrls = reactive({
before: useObjectUrl(comparisonBeforeFile),
after: useObjectUrl(comparisonAfterFile)
})
const hasBothComparisonImages = computed(
() =>
!!(comparisonPreviewUrls.value.before && comparisonPreviewUrls.value.after)
() => !!(comparisonPreviewUrls.before && comparisonPreviewUrls.after)
)
const comparisonPreviewRef = ref<HTMLElement | null>(null)
@@ -331,24 +287,22 @@ const previewSliderPosition = useSliderFromMouse(comparisonPreviewRef)
const hasThumbnailContent = computed(() => {
if (thumbnailType === 'imageComparison') {
return !!(
comparisonPreviewUrls.value.before || comparisonPreviewUrls.value.after
)
return !!(comparisonPreviewUrls.before || comparisonPreviewUrls.after)
}
return !!thumbnailPreviewUrl.value
})
function clearAllPreviews() {
if (thumbnailType === 'imageComparison') {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
emit('update:thumbnailUrl', null)
emit('update:comparisonAfterUrl', null)
return
}
thumbnailFile.value = null
emit('update:thumbnailFile', null)
emit('update:thumbnailUrl', null)
}
function handleFileSelect(event: Event) {
@@ -395,15 +349,19 @@ const comparisonSlots = [
}
]
const comparisonFiles: Record<ComparisonSlot, typeof comparisonBeforeFile> = {
before: comparisonBeforeFile,
after: comparisonAfterFile
}
function setComparisonPreview(file: File, slot: ComparisonSlot) {
if (isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
comparisonFiles[slot].value = file
if (slot === 'before') {
emit('update:comparisonBeforeFile', file)
emit('update:thumbnailUrl', null)
return
}
emit('update:comparisonAfterFile', file)
emit('update:comparisonAfterUrl', null)
}
function handleComparisonSelect(event: Event, slot: ComparisonSlot) {

View File

@@ -58,11 +58,8 @@ function createFormData(
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
@@ -150,110 +147,6 @@ describe('useComfyHubPublishSubmission', () => {
)
})
it('sends the existing thumbnail URL when no new file is attached', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
existingThumbnailType: 'image'
})
)
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'https://cdn.example.com/existing-thumb.png'
})
)
})
it('sends the existing comparison URLs when no new files are attached', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonTokenOrUrl: 'https://cdn.example.com/after.png'
})
)
})
it('does not submit an existing thumbnail URL after the type is switched away', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'video',
thumbnailFile: null,
thumbnailUrl: 'https://cdn.example.com/existing-image.png',
existingThumbnailType: 'image'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'video',
thumbnailTokenOrUrl: undefined
})
)
})
it('prefers a newly uploaded thumbnail file over the existing URL', async () => {
const thumbnailFile = new File(['thumbnail'], 'new-thumb.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
existingThumbnailType: 'image'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('prefers a newly uploaded comparison-after file over the existing URL', async () => {
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
comparisonBeforeFile: null,
comparisonAfterFile: afterFile,
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailComparisonTokenOrUrl: 'token-1'
})
)
})
it('uploads all example images', async () => {
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })

View File

@@ -74,21 +74,14 @@ export function useComfyHubPublishSubmission() {
await workflowShareService.getShareableAssets()
)
const keepsExistingThumbnail =
formData.existingThumbnailType === formData.thumbnailType
const thumbnailFile = resolveThumbnailFile(formData)
const thumbnailTokenOrUrl = thumbnailFile
? await uploadFileAndGetToken(thumbnailFile)
: keepsExistingThumbnail
? (formData.thumbnailUrl ?? undefined)
: undefined
: undefined
const thumbnailComparisonTokenOrUrl =
formData.thumbnailType === 'imageComparison'
? formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: keepsExistingThumbnail
? (formData.comparisonAfterUrl ?? undefined)
: undefined
formData.thumbnailType === 'imageComparison' &&
formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: undefined
const sampleImageTokensOrUrls =

View File

@@ -142,60 +142,4 @@ describe('useComfyHubPublishWizard', () => {
expect(currentStep.value).toBe('finish')
})
})
describe('applyPrefill', () => {
it('restores the existing thumbnail URL into the form', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/thumb.png'
)
})
it('restores the comparison-after URL into the form', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonUrl: 'https://cdn.example.com/after.png'
})
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/before.png'
)
expect(formData.value.comparisonAfterUrl).toBe(
'https://cdn.example.com/after.png'
)
expect(formData.value.existingThumbnailType).toBe('imageComparison')
})
it('does not overwrite a freshly attached thumbnail file with the prefill URL', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
const file = new File(['x'], 'thumb.png', { type: 'image/png' })
formData.value = { ...formData.value, thumbnailFile: file }
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
expect(formData.value.thumbnailFile?.name).toBe('thumb.png')
expect(formData.value.thumbnailUrl).toBeNull()
})
it('restores description, tags, and sample images alongside the thumbnail', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({
description: 'Restored description',
tags: ['art'],
thumbnailUrl: 'https://cdn.example.com/thumb.png',
sampleImageUrls: ['https://cdn.example.com/sample.png']
})
expect(formData.value.description).toBe('Restored description')
expect(formData.value.tags).toEqual(['art'])
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/thumb.png'
)
expect(formData.value.exampleImages).toHaveLength(1)
expect(formData.value.exampleImages[0].url).toBe(
'https://cdn.example.com/sample.png'
)
})
})
})

View File

@@ -32,11 +32,8 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
@@ -54,8 +51,6 @@ function extractPrefillFromFormData(
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
thumbnailType: formData.thumbnailType,
thumbnailUrl: formData.thumbnailUrl ?? undefined,
thumbnailComparisonUrl: formData.comparisonAfterUrl ?? undefined,
sampleImageUrls: formData.exampleImages
.map((img) => img.url)
.filter((url) => !url.startsWith('blob:'))
@@ -100,13 +95,6 @@ export function useComfyHubPublishWizard() {
function applyPrefill(prefill: PublishPrefill) {
const defaults = createDefaultFormData()
const current = formData.value
const hasThumbnail = !!(current.thumbnailFile || current.thumbnailUrl)
const hasComparisonAfter = !!(
current.comparisonAfterFile || current.comparisonAfterUrl
)
const restoredThumbnailUrl = hasThumbnail
? current.thumbnailUrl
: (prefill.thumbnailUrl ?? current.thumbnailUrl)
formData.value = {
...current,
description:
@@ -121,14 +109,6 @@ export function useComfyHubPublishWizard() {
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType,
thumbnailUrl: restoredThumbnailUrl,
existingThumbnailType:
restoredThumbnailUrl && !current.thumbnailFile
? (prefill.thumbnailType ?? current.existingThumbnailType)
: current.existingThumbnailType,
comparisonAfterUrl: hasComparisonAfter
? current.comparisonAfterUrl
: (prefill.thumbnailComparisonUrl ?? current.comparisonAfterUrl),
exampleImages:
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
? createExampleImagesFromUrls(prefill.sampleImageUrls)

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
zHubWorkflowPrefillResponse,
zSharedWorkflowResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
import { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
function makePayload(name: string) {
return {
@@ -55,18 +52,3 @@ describe('zSharedWorkflowResponse name sanitization', () => {
expect(result.name).toBe('spaced name')
})
})
describe('zHubWorkflowPrefillResponse tag tolerance', () => {
it('drops a malformed tag without discarding the rest of the prefill', () => {
const result = zHubWorkflowPrefillResponse.safeParse({
description: 'A cool workflow',
thumbnail_url: 'https://cdn.example.com/thumb.png',
tags: [{ name: 'art', display_name: 'Art' }, 'rawtag', { name: 'broken' }]
})
expect(result.success).toBe(true)
expect(result.data?.tags).toEqual(['Art', 'rawtag'])
expect(result.data?.description).toBe('A cool workflow')
expect(result.data?.thumbnail_url).toBe('https://cdn.example.com/thumb.png')
})
})

View File

@@ -10,18 +10,9 @@ export const zPublishRecordResponse = z.object({
assets: z.array(zAssetInfo).optional()
})
const zPrefillTag = z
.object({ name: z.string(), display_name: z.string() })
.transform((label) => label.display_name)
.or(z.string())
const zPrefillTagList = z
.array(zPrefillTag.optional().catch(undefined))
.transform((tags) => tags.filter((tag): tag is string => tag !== undefined))
export const zHubWorkflowPrefillResponse = z.object({
description: z.string().nullish(),
tags: zPrefillTagList.nullish(),
tags: z.array(z.string()).nullish(),
sample_image_urls: z.array(z.string()).nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
thumbnail_url: z.string().nullish(),

View File

@@ -177,10 +177,7 @@ describe(useWorkflowShareService, () => {
if (path === '/hub/workflows/wf-prefill') {
return mockJsonResponse({
description: 'A cool workflow',
tags: [
{ name: 'art', display_name: 'Art' },
{ name: 'upscale', display_name: 'Upscale' }
],
tags: ['art', 'upscale'],
thumbnail_type: 'image_comparison',
sample_image_urls: ['https://example.com/img1.png']
})
@@ -195,7 +192,7 @@ describe(useWorkflowShareService, () => {
expect(status.isPublished).toBe(true)
expect(status.prefill).toEqual({
description: 'A cool workflow',
tags: ['Art', 'Upscale'],
tags: ['art', 'upscale'],
thumbnailType: 'imageComparison',
sampleImageUrls: ['https://example.com/img1.png']
})

View File

@@ -45,8 +45,6 @@ interface PrefillMetadataFields {
description?: string | null
tags?: string[] | null
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
thumbnail_url?: string | null
thumbnail_comparison_url?: string | null
sample_image_urls?: string[] | null
}
@@ -54,29 +52,18 @@ function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
const description = fields.description ?? undefined
const tags = fields.tags ?? undefined
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
const thumbnailUrl = fields.thumbnail_url ?? undefined
const thumbnailComparisonUrl = fields.thumbnail_comparison_url ?? undefined
const sampleImageUrls = fields.sample_image_urls ?? undefined
if (
!description &&
!tags?.length &&
!thumbnailType &&
!thumbnailUrl &&
!thumbnailComparisonUrl &&
!sampleImageUrls?.length
) {
return null
}
return {
description,
tags,
thumbnailType,
thumbnailUrl,
thumbnailComparisonUrl,
sampleImageUrls
}
return { description, tags, thumbnailType, sampleImageUrls }
}
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {

View File

@@ -14,11 +14,8 @@ export interface ComfyHubPublishFormData {
customNodes: string[]
thumbnailType: ThumbnailType
thumbnailFile: File | null
thumbnailUrl: string | null
existingThumbnailType: ThumbnailType | null
comparisonBeforeFile: File | null
comparisonAfterFile: File | null
comparisonAfterUrl: string | null
exampleImages: ExampleImage[]
tutorialUrl: string
metadata: Record<string, unknown>

View File

@@ -15,8 +15,6 @@ export interface PublishPrefill {
description?: string
tags?: string[]
thumbnailType?: ThumbnailType
thumbnailUrl?: string
thumbnailComparisonUrl?: string
sampleImageUrls?: string[]
}