Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
9719157a19 test: cover graph selection and menu composables 2026-06-30 22:37:00 -07:00
18 changed files with 1622 additions and 1571 deletions

View File

@@ -245,7 +245,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const assetId = String(jobId)
openAssetsSidebar()
await nextTick()
await assetsStore.refreshHistoryHead()
await assetsStore.updateHistory()
const asset = assetsStore.historyAssets.find(
(existingAsset) => existingAsset.id === assetId
)

View File

@@ -0,0 +1,217 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = useGroupMenuOptions().getGroupShapeOptions(
group({ nodes: [node] }),
bump
)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
useGroupMenuOptions()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().getGroupModeOptions(
group({ nodes: undefined }),
vi.fn()
)
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = useGroupMenuOptions().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [node] }),
bump
)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -0,0 +1,294 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -0,0 +1,175 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = useNodeCustomization()
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = useNodeCustomization()
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
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 }
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
useSelectedNodeActions: () => actions
}))
const i18n = createI18n({
@@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
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', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => {
])
).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

@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToCopy',
detail: 'g.selectItemsToCopy',
life: 3000
})
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'g.copied',
detail: 'g.itemsCopiedToClipboard',
life: 2000
})
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDuplicate',
detail: 'g.selectItemsToDuplicate',
life: 3000
})
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDelete',
detail: 'g.selectItemsToDelete',
life: 3000
})
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToRename',
detail: 'g.selectItemsToRename',
life: 3000
})
})
})

View File

@@ -8,7 +8,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -17,7 +22,9 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -96,6 +103,16 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return (
typedNode?.type === 'Load3D' || typedNode?.type === 'Load3DAnimation'
)
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -135,6 +152,21 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -215,6 +247,13 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -12,6 +14,7 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -123,6 +126,47 @@ function createMockNode(
})
}
async function flushMacrotask(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
async function resolveDisplayPrice(
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): Promise<string> {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const startRevision = pricingRevision.value
// Wait on pricingRevision (bumped when async evaluation settles) instead of a
// fixed sleep. A cache hit schedules nothing, so the bounded poll falls through.
getNodeDisplayPrice(node, widgetOverrides)
for (let attempt = 0; attempt < 50; attempt++) {
if (pricingRevision.value !== startRevision) break
await flushMacrotask()
}
return getNodeDisplayPrice(node, widgetOverrides)
}
function createStoredNodeDef(
name: string,
price_badge?: PriceBadge
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
price_badge
} satisfies ComfyNodeDef
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -189,6 +233,32 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -222,6 +292,19 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -238,6 +321,51 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -468,6 +596,42 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -595,6 +759,86 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -743,6 +987,24 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should reuse the cached empty label after runtime failures', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestCachedRuntimeErrorNode',
priceBadge('$lookup(undefined, "key")')
)
expect(await resolveDisplayPrice(node)).toBe('')
const revisionAfterFailure = pricingRevision.value
// A second read of the same signature must reuse the cached empty label
// without scheduling another evaluation (no revision bump).
expect(getNodeDisplayPrice(node)).toBe('')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(getNodeDisplayPrice(node)).toBe('')
expect(pricingRevision.value).toBe(revisionAfterFailure)
})
it('should return empty string for invalid PricingResult type', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -968,8 +1230,21 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
expect(result).toBe('')
})
})
@@ -999,6 +1274,14 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1017,6 +1300,22 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1024,6 +1323,11 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1190,6 +1494,29 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1265,6 +1592,30 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -255,13 +255,13 @@ describe('resolveMissingMediaAssetSources', () => {
1,
expect.any(Function),
200,
{ offset: 0 }
0
)
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
2,
expect.any(Function),
200,
{ offset: 200 }
200
)
})

View File

@@ -176,7 +176,7 @@ async function fetchGeneratedHistoryAssets(
const historyPage = await fetchHistoryPage(
api.fetchApi.bind(api),
HISTORY_MEDIA_ASSETS_PAGE_SIZE,
{ offset: requestedOffset }
requestedOffset
)
signal?.throwIfAborted()

View File

@@ -709,7 +709,7 @@ describe('verifyMediaCandidates', () => {
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
expect.any(Function),
200,
{ offset: 0 }
0
)
expect(candidates[0]).toMatchObject({
name: 'subfolder/photo.png [output]',
@@ -843,13 +843,13 @@ describe('verifyMediaCandidates', () => {
1,
expect.any(Function),
200,
{ offset: 0 }
0
)
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
2,
expect.any(Function),
200,
{ offset: 200 }
200
)
expect(candidates[0].isMissing).toBe(false)
})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
JobsApiError,
extractWorkflow,
fetchHistory,
fetchHistoryPage,
@@ -40,8 +39,7 @@ function createMockResponse(
offset: pagination.offset ?? 0,
limit: pagination.limit ?? 200,
total,
has_more: pagination.has_more ?? false,
next_cursor: pagination.next_cursor
has_more: pagination.has_more ?? false
}
}
}
@@ -137,85 +135,23 @@ describe('fetchJobs', () => {
expect(result[0].priority).toBe(999)
})
it('propagates fetch errors', async () => {
it('returns empty array on error', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
await expect(fetchHistory(mockFetch)).rejects.toThrow('Network error')
const result = await fetchHistory(mockFetch)
expect(result).toEqual([])
})
it('throws a JobsApiError carrying status, body, and parsed errorCode on non-ok response', async () => {
it('returns empty array on non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: () =>
Promise.resolve(
'{"code":"INVALID_CURSOR","message":"Invalid pagination cursor"}'
)
status: 500
})
await expect(fetchHistory(mockFetch)).rejects.toBeInstanceOf(JobsApiError)
await expect(fetchHistory(mockFetch)).rejects.toMatchObject({
status: 400,
errorCode: 'INVALID_CURSOR',
message: expect.stringContaining('INVALID_CURSOR')
})
})
const result = await fetchHistory(mockFetch)
it('leaves errorCode undefined when the body is not the structured error shape', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 502,
text: () => Promise.resolve('<html>Bad Gateway</html>')
})
const err = await fetchHistory(mockFetch).catch((e) => e)
expect(err).toBeInstanceOf(JobsApiError)
expect(err.errorCode).toBeUndefined()
})
it('leaves errorCode undefined for an oversized body rather than parsing it', async () => {
const oversized = `{"code":"INVALID_CURSOR","pad":"${'x'.repeat(20_000)}"}`
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: () => Promise.resolve(oversized)
})
const err = await fetchHistory(mockFetch).catch((e) => e)
expect(err).toBeInstanceOf(JobsApiError)
expect(err.errorCode).toBeUndefined()
})
it('truncates oversized error bodies to 200 chars in the thrown message', async () => {
const oversized = 'x'.repeat(500)
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve(oversized)
})
const err = await fetchHistory(mockFetch).catch((e) => e)
expect(err).toBeInstanceOf(JobsApiError)
expect(err.message.length).toBeLessThanOrEqual(
'[Jobs API] Failed to fetch jobs: 500 '.length + 200 + 1 // +1 for the ellipsis
)
expect(err.message).toContain('…')
})
it('parses a null next_cursor as absent', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')], 1, {
next_cursor: null
})
)
})
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
expect(result.nextCursor).toBeUndefined()
expect(result).toEqual([])
})
it('parses batch containing text-only preview outputs', async () => {
@@ -269,7 +205,7 @@ describe('fetchJobs', () => {
)
})
const result = await fetchHistoryPage(mockFetch, 2, { offset: 5 })
const result = await fetchHistoryPage(mockFetch, 2, 5)
expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=2&offset=5'
@@ -282,79 +218,6 @@ describe('fetchJobs', () => {
expect(result.jobs[0].priority).toBe(5)
expect(result.jobs[1].priority).toBe(4)
})
it('sends the cursor instead of offset and returns next_cursor', async () => {
const mockFetch = vi
.fn<(url: string) => Promise<Response>>()
.mockResolvedValue(
new Response(
JSON.stringify(
createMockResponse([createMockJob('job1', 'completed')], 10, {
has_more: true,
next_cursor: 'cursor-page-2'
})
),
{ status: 200 }
)
)
const result = await fetchHistoryPage(mockFetch, 200, {
after: 'cursor-page-1'
})
expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&after=cursor-page-1'
)
expect(result.nextCursor).toBe('cursor-page-2')
expect(result.hasMore).toBe(true)
})
it('uri-encodes the cursor', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(createMockResponse([]))
})
await fetchHistoryPage(mockFetch, 200, { after: 'a+b/c=' })
expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&after=a%2Bb%2Fc%3D'
)
})
it('returns next_cursor from offset-mode responses for cursor bootstrap', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')], 10, {
has_more: true,
next_cursor: 'minted-in-offset-mode'
})
)
})
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&offset=0'
)
expect(result.nextCursor).toBe('minted-in-offset-mode')
})
it('omits nextCursor when the server does not mint one', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')])
)
})
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
expect(result.nextCursor).toBeUndefined()
})
})
describe('fetchQueue', () => {
@@ -405,10 +268,12 @@ describe('fetchJobs', () => {
)
})
it('propagates fetch errors', async () => {
it('returns empty arrays on error', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
await expect(fetchQueue(mockFetch)).rejects.toThrow('Network error')
const result = await fetchQueue(mockFetch)
expect(result).toEqual({ Running: [], Pending: [] })
})
})

View File

@@ -6,8 +6,6 @@
* All distributions use the /jobs endpoint.
*/
import { z } from 'zod'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { JobId } from '@/schemas/apiSchema'
@@ -20,64 +18,12 @@ import type {
} from './jobTypes'
import { zJobDetail, zJobsListResponse, zWorkflowContainer } from './jobTypes'
/**
* Position of the page to fetch. `after` is an opaque keyset cursor from a
* prior response's `nextCursor` and takes precedence over `offset`; `offset`
* remains as the fallback for random access and for backends that don't mint
* cursors.
*/
export type JobsPageRequest =
| { after: string; offset?: never }
| { offset?: number; after?: never }
/**
* Non-ok response from the jobs API. Carries the HTTP status and the parsed
* machine-readable `errorCode` (from the JSON error body) so callers can tell a
* rejected cursor (`INVALID_CURSOR`) apart from other 400s and transient
* failures. `errorCode` is undefined when the body isn't the structured error
* shape (e.g. a proxy error page).
*/
const MAX_ERROR_BODY_LENGTH = 200
// Cap synchronous JSON.parse so an oversized error body can't block the UI
// thread; the structured error is a few hundred bytes at most.
const MAX_ERROR_PARSE_LENGTH = 10_000
const zJobsErrorBody = z.object({ code: z.string() })
function parseErrorCode(body: string): string | undefined {
if (body.length > MAX_ERROR_PARSE_LENGTH) return undefined
try {
return zJobsErrorBody.safeParse(JSON.parse(body)).data?.code
} catch {
return undefined
}
}
export class JobsApiError extends Error {
readonly errorCode?: string
constructor(
readonly status: number,
body: string
) {
const truncated =
body.length > MAX_ERROR_BODY_LENGTH
? `${body.slice(0, MAX_ERROR_BODY_LENGTH)}`
: body
super(`[Jobs API] Failed to fetch jobs: ${status} ${truncated}`.trim())
this.name = 'JobsApiError'
this.errorCode = parseErrorCode(body)
}
}
interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
limit: number
hasMore: boolean
nextCursor?: string
}
export interface FetchHistoryPageResult {
@@ -86,39 +32,43 @@ export interface FetchHistoryPageResult {
offset: number
limit: number
hasMore: boolean
nextCursor?: string
}
/**
* Fetches raw jobs from /jobs endpoint.
* Throws on failure so callers can tell a failed page apart from an empty
* last page (e.g. a stale cursor rejected with 400 INVALID_CURSOR).
* Fetches raw jobs from /jobs endpoint
* @internal
*/
async function fetchJobsRaw(
fetchApi: (url: string) => Promise<Response>,
statuses: JobStatus[],
maxItems: number = 200,
page: JobsPageRequest = {}
offset: number = 0
): Promise<FetchJobsRawResult> {
const statusParam = statuses.join(',')
const pageParam =
page.after != null
? `after=${encodeURIComponent(page.after)}`
: `offset=${page.offset ?? 0}`
const url = `/jobs?status=${statusParam}&limit=${maxItems}&${pageParam}`
const res = await fetchApi(url)
if (!res.ok) {
throw new JobsApiError(res.status, await res.text().catch(() => ''))
}
const data = zJobsListResponse.parse(await res.json())
return {
jobs: data.jobs,
total: data.pagination.total,
offset: data.pagination.offset,
limit: data.pagination.limit,
hasMore: data.pagination.has_more,
nextCursor: data.pagination.next_cursor ?? undefined
const url = `/jobs?status=${statusParam}&limit=${maxItems}&offset=${offset}`
try {
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
return {
jobs: [],
total: 0,
offset,
limit: maxItems,
hasMore: false
}
}
const data = zJobsListResponse.parse(await res.json())
return {
jobs: data.jobs,
total: data.pagination.total,
offset: data.pagination.offset,
limit: data.pagination.limit,
hasMore: data.pagination.has_more
}
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
}
}
@@ -148,7 +98,7 @@ export async function fetchHistory(
maxItems: number = 200,
offset: number = 0
): Promise<JobListItem[]> {
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, { offset })
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
return jobs
}
@@ -158,13 +108,13 @@ export async function fetchHistory(
export async function fetchHistoryPage(
fetchApi: (url: string) => Promise<Response>,
maxItems: number = 200,
page: JobsPageRequest = {}
offset: number = 0
): Promise<FetchHistoryPageResult> {
const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
page
offset
)
// History gets priority based on total count (lower than queue)
@@ -173,8 +123,7 @@ export async function fetchHistoryPage(
total: result.total,
offset: result.offset,
limit: result.limit,
hasMore: result.hasMore,
nextCursor: result.nextCursor
hasMore: result.hasMore
}
}
@@ -185,7 +134,12 @@ export async function fetchHistoryPage(
export async function fetchQueue(
fetchApi: (url: string) => Promise<Response>
): Promise<{ Running: JobListItem[]; Pending: JobListItem[] }> {
const { jobs } = await fetchJobsRaw(fetchApi, ['in_progress', 'pending'])
const { jobs } = await fetchJobsRaw(
fetchApi,
['in_progress', 'pending'],
200,
0
)
const running = jobs.filter((j) => j.status === 'in_progress')
const pending = jobs.filter((j) => j.status === 'pending')

View File

@@ -87,8 +87,7 @@ const zPaginationInfo = z.object({
offset: z.number(),
limit: z.number(),
total: z.number(),
has_more: z.boolean(),
next_cursor: z.string().min(1).nullish()
has_more: z.boolean()
})
export const zJobsListResponse = z.object({

File diff suppressed because it is too large Load Diff

View File

@@ -17,14 +17,6 @@ import {
} from '@/platform/assets/services/assetService'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import {
JobsApiError,
fetchHistoryPage
} from '@/platform/remote/comfyui/jobs/fetchJobs'
import type {
FetchHistoryPageResult,
JobsPageRequest
} from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
@@ -122,9 +114,8 @@ export const useAssetsStore = defineStore('assets', () => {
return deletingAssetIds.has(assetId)
}
// History pagination state
// Pagination state
const historyOffset = ref(0)
const historyNextCursor = ref<string | null>(null)
const hasMoreHistory = ref(true)
const isLoadingMore = ref(false)
@@ -132,12 +123,6 @@ export const useAssetsStore = defineStore('assets', () => {
const loadedIds = shallowReactive(new Set<string>())
// Ids of every raw job walked so far, including ones that map to no
// displayable asset (failed, cancelled, preview-less). Head-refresh gap
// detection needs the full set: a burst of non-asset jobs at the top would
// otherwise never overlap `loadedIds` and trigger a needless full reload.
const loadedJobIds = new Set<string>()
const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
: fetchInputFilesFromAPI
@@ -162,141 +147,65 @@ export const useAssetsStore = defineStore('assets', () => {
}
/**
* Insert assets in sorted order (newest first), skipping already-loaded ids
*/
const mergeHistoryAssets = (newAssets: AssetItem[]) => {
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue
}
loadedIds.add(asset.id)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
allHistoryItems.value.push(asset)
} else {
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
}
const trimHistoryToLimit = () => {
if (allHistoryItems.value.length <= MAX_HISTORY_ITEMS) return
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
removed.forEach((item) => loadedIds.delete(item.id))
}
const fetchHistoryJobsPage = (page: JobsPageRequest) =>
fetchHistoryPage(api.fetchApi.bind(api), BATCH_SIZE, page)
// Invalidates in-flight history fetches whenever the list is replaced, so
// a stale continuation can't merge into (or move the cursor of) the new walk.
let historyFetchEpoch = 0
// Tracks whether the walk is keyset-paginated, independent of the current
// cursor value: once a cursor has been minted the walk stays in cursor mode
// even after it exhausts (`historyNextCursor` back to null), so head-refresh
// merges keep preserving scroll-loaded items instead of replacing them.
let historyCursorMode = false
const isRejectedCursorError = (err: unknown): boolean =>
err instanceof JobsApiError &&
err.status === 400 &&
err.errorCode === 'INVALID_CURSOR'
const fetchHistoryPageWithCursorRecovery = async (
after: string | null,
epoch: number
): Promise<FetchHistoryPageResult> => {
if (after == null)
return fetchHistoryJobsPage({ offset: historyOffset.value })
try {
return await fetchHistoryJobsPage({ after })
} catch (err) {
// Drop only a rejected cursor (e.g. stale across a restart) to the
// offset fallback; transient failures and superseded-walk
// continuations must propagate so a valid/newer cursor isn't lost.
if (!isRejectedCursorError(err) || epoch !== historyFetchEpoch) throw err
console.warn('Stale history cursor rejected, resuming via offset:', err)
historyNextCursor.value = null
historyCursorMode = false
historyOffset.value = 0
allHistoryItems.value = []
loadedIds.clear()
loadedJobIds.clear()
return fetchHistoryJobsPage({ offset: 0 })
}
}
/**
* Fetch one page of history assets and update reactive state.
*
* Pagination model: the server starts in offset mode and mints a
* `next_cursor` on any page that has one; subsequent requests pass that
* cursor (keyset mode). The walk upgrades automatically — offset paging is
* only used until the first cursor is received.
*
* An empty page with no cursor is treated as terminal regardless of
* `has_more`, because offset paging would refetch the same page forever.
* A cursor that hasn't advanced (the server echoed back the value it was
* given) is also treated as terminal to prevent an infinite dedup loop.
*
* @param loadMore - When `true`, appends the next page to the existing list
* (infinite-scroll continuation). When `false` (default), resets all
* pagination state and replaces the list with the first page.
* @returns The current accumulated list of history asset items.
* Fetch history assets with pagination support
* @param loadMore - true for pagination (append), false for initial load (replace)
*/
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyFetchEpoch += 1
historyOffset.value = 0
historyNextCursor.value = null
historyCursorMode = false
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
loadedJobIds.clear()
}
const epoch = historyFetchEpoch
const requestedAfter = loadMore ? historyNextCursor.value : null
const page = await fetchHistoryPageWithCursorRecovery(requestedAfter, epoch)
if (epoch !== historyFetchEpoch) return allHistoryItems.value
// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})
page.jobs.forEach((job) => loadedJobIds.add(job.id))
const newAssets = mapHistoryToAssets(page.jobs)
// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)
if (loadMore) {
mergeHistoryAssets(newAssets)
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
}
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
// Asset is oldest, append to end
allHistoryItems.value.push(asset)
} else {
// Insert at the correct position
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
// Initial load: replace all
allHistoryItems.value = newAssets
newAssets.forEach((asset) => loadedIds.add(asset.id))
}
const cursorStuck =
page.nextCursor != null && page.nextCursor === requestedAfter
if (page.nextCursor != null) historyCursorMode = true
// The server ignores `offset` once the walk is keyset-paginated, so only
// advance it while still in offset mode; otherwise the offset used by the
// recovery fallback would drift past valid rows.
if (!historyCursorMode) historyOffset.value += page.jobs.length
hasMoreHistory.value =
page.hasMore &&
!cursorStuck &&
(page.jobs.length > 0 || page.nextCursor != null)
// Drop the cursor once paging terminates so state never carries a live
// cursor alongside `hasMoreHistory === false`.
historyNextCursor.value = hasMoreHistory.value
? (page.nextCursor ?? null)
: null
// Update pagination state
historyOffset.value += BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
trimHistoryToLimit()
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
// Clean up Set
removed.forEach((item) => loadedIds.delete(item.id))
}
return allHistoryItems.value
}
@@ -336,15 +245,13 @@ export const useAssetsStore = defineStore('assets', () => {
isLoadingMore.value = true
historyError.value = null
const epoch = historyFetchEpoch
try {
await fetchHistoryAssets(true)
if (epoch !== historyFetchEpoch) return
historyAssets.value = allHistoryItems.value
} catch (err) {
if (epoch !== historyFetchEpoch) return
console.error('Error loading more history:', err)
historyError.value = err
// Keep existing data when error occurs (consistent with updateHistory)
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -353,100 +260,6 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* A head page with no further rows spans the whole timeline, so replacing
* local state with it also prunes jobs deleted server-side (e.g. after the
* queue history is cleared from another surface).
*
* Bumps `historyFetchEpoch`, which cancels any concurrent
* `loadMoreHistory`/`fetchHistoryAssets` continuation.
*/
const replaceHistoryWithHeadPage = (page: FetchHistoryPageResult) => {
historyFetchEpoch += 1
const newAssets = mapHistoryToAssets(page.jobs)
allHistoryItems.value = newAssets
loadedIds.clear()
newAssets.forEach((asset) => loadedIds.add(asset.id))
loadedJobIds.clear()
page.jobs.forEach((job) => loadedJobIds.add(job.id))
historyOffset.value = page.jobs.length
historyNextCursor.value = page.nextCursor ?? null
historyCursorMode = page.nextCursor != null
hasMoreHistory.value = page.hasMore
}
let headRefreshInFlight: Promise<void> | null = null
let headRefreshTrailing: Promise<void> | null = null
/**
* Merge newly completed jobs into the top of the list without resetting
* pagination state, so items loaded via infinite scroll survive the
* refresh. Cursors only walk toward older items, so new completions are
* picked up by re-fetching the head page and deduplicating. Bursts of
* status events share the in-flight refresh, and a call arriving
* mid-flight schedules exactly one trailing refresh — the shared response
* was dispatched before that caller's event, so it could miss the very
* completion the caller is reacting to.
*/
const refreshHistoryHead = (): Promise<void> => {
if (!headRefreshInFlight) {
headRefreshInFlight = doRefreshHistoryHead().finally(() => {
headRefreshInFlight = null
})
return headRefreshInFlight
}
headRefreshTrailing ??= headRefreshInFlight.then(() => {
headRefreshTrailing = null
return refreshHistoryHead()
})
return headRefreshTrailing
}
const doRefreshHistoryHead = async () => {
historyError.value = null
if (!allHistoryItems.value.length) {
await updateHistory()
return
}
let epoch = historyFetchEpoch
try {
const page = await fetchHistoryJobsPage({ offset: 0 })
if (epoch !== historyFetchEpoch) return
const reachesLoadedItems = page.jobs.some((job) =>
loadedJobIds.has(job.id)
)
if (page.hasMore && !reachesLoadedItems) {
await updateHistory()
return
}
// Merging only preserves scroll-loaded items safely in cursor mode,
// including once the cursor has exhausted (historyNextCursor is null but
// the loaded terminal pages must survive). In offset fallback mode,
// prepending new head rows without advancing historyOffset would drift
// the next offset request (the server timeline shifted down by the new
// completions), so rebuild from the head page — which resets
// historyOffset to a position consistent with that page.
if (page.hasMore && historyCursorMode) {
page.jobs.forEach((job) => loadedJobIds.add(job.id))
mergeHistoryAssets(mapHistoryToAssets(page.jobs))
trimHistoryToLimit()
} else {
replaceHistoryWithHeadPage(page)
// replaceHistoryWithHeadPage bumps the epoch; re-sync so the catch
// guard below suppresses stale continuations, not genuine errors.
epoch = historyFetchEpoch
}
historyAssets.value = allHistoryItems.value
} catch (err) {
if (epoch !== historyFetchEpoch) return
console.error('Error refreshing history:', err)
historyError.value = err
}
}
const flatOutputAssets = ref<AssetItem[]>([])
const flatOutputLoading = ref(false)
const flatOutputError = ref<unknown>(null)
@@ -1071,7 +884,6 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
refreshHistoryHead,
setAssetPreview,
// Flat output assets (cloud-only, tag-based)

View File

@@ -100,10 +100,7 @@ vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ isBuilderMode: ref(false) })
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
updateHistory: vi.fn(),
refreshHistoryHead: vi.fn()
})
useAssetsStore: () => ({ updateHistory: vi.fn() })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ registerCommands: vi.fn() })

View File

@@ -238,7 +238,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.refreshHistoryHead()
await assetsStore.updateHistory()
}
}
@@ -247,7 +247,7 @@ const onExecutionSuccess = async () => {
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.refreshHistoryHead()
await assetsStore.updateHistory()
}
}