mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 04:31:58 +00:00
workflow caching cascade persistence
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAX_DRAFTS,
|
||||
type WorkflowDraftSnapshot,
|
||||
createDraftCacheState,
|
||||
mostRecentDraftPath,
|
||||
moveDraft,
|
||||
removeDraft,
|
||||
touchEntry,
|
||||
upsertDraft
|
||||
} from '@/platform/workflow/persistence/base/draftCache'
|
||||
|
||||
const createSnapshot = (name: string): WorkflowDraftSnapshot => ({
|
||||
data: JSON.stringify({ name }),
|
||||
updatedAt: Date.now(),
|
||||
name,
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
describe('draftCache helpers', () => {
|
||||
it('touchEntry moves path to end', () => {
|
||||
expect(touchEntry(['a', 'b'], 'a')).toEqual(['b', 'a'])
|
||||
expect(touchEntry(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('upsertDraft stores snapshot and applies LRU', () => {
|
||||
let state = createDraftCacheState()
|
||||
for (let i = 0; i < MAX_DRAFTS; i++) {
|
||||
const path = `workflows/Draft${i}.json`
|
||||
state = upsertDraft(state, path, createSnapshot(String(i)))
|
||||
}
|
||||
|
||||
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
|
||||
|
||||
state = upsertDraft(state, 'workflows/New.json', createSnapshot('new'))
|
||||
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
|
||||
expect(state.drafts).not.toHaveProperty('workflows/Draft0.json')
|
||||
expect(state.order[state.order.length - 1]).toBe('workflows/New.json')
|
||||
})
|
||||
|
||||
it('removeDraft clears entry and order', () => {
|
||||
const state = upsertDraft(
|
||||
createDraftCacheState(),
|
||||
'workflows/test.json',
|
||||
createSnapshot('test')
|
||||
)
|
||||
|
||||
const nextState = removeDraft(state, 'workflows/test.json')
|
||||
expect(nextState.drafts).toEqual({})
|
||||
expect(nextState.order).toEqual([])
|
||||
})
|
||||
|
||||
it('moveDraft renames entry and updates order', () => {
|
||||
const state = upsertDraft(
|
||||
createDraftCacheState(),
|
||||
'workflows/old.json',
|
||||
createSnapshot('old')
|
||||
)
|
||||
|
||||
const nextState = moveDraft(
|
||||
state,
|
||||
'workflows/old.json',
|
||||
'workflows/new.json',
|
||||
'new'
|
||||
)
|
||||
expect(nextState.drafts).not.toHaveProperty('workflows/old.json')
|
||||
expect(nextState.drafts['workflows/new.json']?.name).toBe('new')
|
||||
expect(nextState.order).toEqual(['workflows/new.json'])
|
||||
})
|
||||
|
||||
it('mostRecentDraftPath returns last entry', () => {
|
||||
const state = createDraftCacheState({}, ['a', 'b', 'c'])
|
||||
expect(mostRecentDraftPath(state.order)).toBe('c')
|
||||
expect(mostRecentDraftPath([])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { setStorageValue } from '@/scripts/utils'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) =>
|
||||
key === 'Comfy.Workflow.Persist' ? true : undefined
|
||||
),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
const loadBlankWorkflow = vi.fn()
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
loadBlankWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
const executeCommand = vi.fn()
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: executeCommand
|
||||
})
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
graphChangedHandler: null as GraphChangedHandler,
|
||||
currentGraph: {} as Record<string, unknown>
|
||||
}
|
||||
const serializeMock = vi.fn(() => state.currentGraph)
|
||||
const loadGraphDataMock = vi.fn()
|
||||
const apiMock = {
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
state.graphChangedHandler = handler
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
deleteUserData: vi.fn(),
|
||||
moveUserData: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => path)
|
||||
}
|
||||
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||
canvas: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mocks.apiMock
|
||||
}))
|
||||
|
||||
describe('useWorkflowPersistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
useWorkflowDraftStore().reset()
|
||||
mocks.state.graphChangedHandler = null
|
||||
mocks.state.currentGraph = { initial: true }
|
||||
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||
mocks.loadGraphDataMock.mockReset()
|
||||
mocks.apiMock.clientId = 'test-client'
|
||||
mocks.apiMock.initialClientId = 'test-client'
|
||||
mocks.apiMock.addEventListener.mockImplementation(
|
||||
(event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
mocks.state.graphChangedHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
|
||||
mocks.apiMock.getUserData.mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(defaultGraphJSON)
|
||||
} as Response)
|
||||
mocks.apiMock.apiURL.mockImplementation((path: string) => path)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('persists snapshots for multiple workflows', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowA = workflowStore.createTemporary('DraftA.json')
|
||||
await workflowStore.openWorkflow(workflowA)
|
||||
|
||||
const persistence = useWorkflowPersistence()
|
||||
expect(persistence).toBeDefined()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
const graphA = { title: 'A' }
|
||||
mocks.state.currentGraph = graphA
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
const workflowB = workflowStore.createTemporary('DraftB.json')
|
||||
await workflowStore.openWorkflow(workflowB)
|
||||
const graphB = { title: 'B' }
|
||||
mocks.state.currentGraph = graphB
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||
) as Record<string, any>
|
||||
|
||||
expect(Object.keys(drafts)).toEqual(
|
||||
expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
|
||||
)
|
||||
expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
|
||||
expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
|
||||
expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
|
||||
expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts least recently used drafts beyond the limit', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
useWorkflowPersistence()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
for (let i = 0; i < 33; i++) {
|
||||
const workflow = workflowStore.createTemporary(`Draft${i}.json`)
|
||||
await workflowStore.openWorkflow(workflow)
|
||||
mocks.state.currentGraph = { index: i }
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
vi.setSystemTime(new Date(Date.now() + 60000))
|
||||
}
|
||||
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||
) as Record<string, any>
|
||||
|
||||
expect(Object.keys(drafts).length).toBe(32)
|
||||
expect(drafts['workflows/Draft0.json']).toBeUndefined()
|
||||
expect(drafts['workflows/Draft32.json']).toBeDefined()
|
||||
})
|
||||
|
||||
it('restores temporary tabs from cached drafts', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const draftData = JSON.parse(defaultGraphJSON)
|
||||
draftStore.saveDraft('workflows/Unsaved Workflow.json', {
|
||||
data: JSON.stringify(draftData),
|
||||
updatedAt: Date.now(),
|
||||
name: 'Unsaved Workflow.json',
|
||||
isTemporary: true
|
||||
})
|
||||
setStorageValue(
|
||||
'Comfy.OpenWorkflowsPaths',
|
||||
JSON.stringify(['workflows/Unsaved Workflow.json'])
|
||||
)
|
||||
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistence()
|
||||
restoreWorkflowTabsState()
|
||||
|
||||
const restored = workflowStore.getWorkflowByPath(
|
||||
'workflows/Unsaved Workflow.json'
|
||||
)
|
||||
expect(restored).toBeTruthy()
|
||||
expect(restored?.isTemporary).toBe(true)
|
||||
expect(
|
||||
workflowStore.openWorkflows.map((workflow) => workflow?.path)
|
||||
).toContain('workflows/Unsaved Workflow.json')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user