mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
Compare commits
7 Commits
core/1.45
...
glary/migr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa0978861 | ||
|
|
b744fe99be | ||
|
|
5efa514c55 | ||
|
|
8e74c8f218 | ||
|
|
822dc6d633 | ||
|
|
b865cd8cf1 | ||
|
|
14cef7126e |
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
generateUUID,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
isCivitaiModelUrl,
|
||||
isCivitaiUrl,
|
||||
isPreviewableMediaType,
|
||||
isValidUuid,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
@@ -445,6 +447,59 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidUuid', () => {
|
||||
it('accepts canonical lowercase UUIDs', () => {
|
||||
expect(isValidUuid('9cea40bb-b0cf-4b40-a758-8935cfe8d52f')).toBe(true)
|
||||
expect(isValidUuid('00000000-0000-0000-0000-000000000000')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts canonical uppercase and mixed-case UUIDs', () => {
|
||||
expect(isValidUuid('9CEA40BB-B0CF-4B40-A758-8935CFE8D52F')).toBe(true)
|
||||
expect(isValidUuid('9cea40BB-b0CF-4b40-A758-8935cfe8D52F')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts any version and variant (not just v4)', () => {
|
||||
expect(isValidUuid('00000000-0000-1000-0000-000000000000')).toBe(true)
|
||||
expect(isValidUuid('ffffffff-ffff-7fff-ffff-ffffffffffff')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes generateUUID output as valid', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(isValidUuid(generateUUID())).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects slug strings from workflow templates', () => {
|
||||
expect(isValidUuid('video-point-prompt-example')).toBe(false)
|
||||
expect(isValidUuid('default')).toBe(false)
|
||||
expect(isValidUuid('my-workflow-v2')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-string inputs', () => {
|
||||
expect(isValidUuid(undefined)).toBe(false)
|
||||
expect(isValidUuid(null)).toBe(false)
|
||||
expect(isValidUuid('')).toBe(false)
|
||||
expect(isValidUuid(0)).toBe(false)
|
||||
expect(isValidUuid(123)).toBe(false)
|
||||
expect(isValidUuid({})).toBe(false)
|
||||
expect(isValidUuid([])).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed UUID-like strings', () => {
|
||||
expect(isValidUuid('9cea40bb-b0cf-4b40-a758-8935cfe8d52')).toBe(false)
|
||||
expect(isValidUuid('9cea40bb-b0cf-4b40-a758-8935cfe8d52fa')).toBe(false)
|
||||
expect(isValidUuid('9cea40bbb0cf4b40a7588935cfe8d52f')).toBe(false)
|
||||
expect(isValidUuid('9cea40bb_b0cf_4b40_a758_8935cfe8d52f')).toBe(false)
|
||||
expect(isValidUuid(' 9cea40bb-b0cf-4b40-a758-8935cfe8d52f')).toBe(false)
|
||||
expect(isValidUuid('9cea40bb-b0cf-4b40-a758-8935cfe8d52f ')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects strings with non-hex characters in UUID slots', () => {
|
||||
expect(isValidUuid('gcea40bb-b0cf-4b40-a758-8935cfe8d52f')).toBe(false)
|
||||
expect(isValidUuid('9cea40bb-b0cf-4b40-a758-8935cfe8d52z')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalizedNumber', () => {
|
||||
it('formats numbers using the given locale', () => {
|
||||
expect(formatLocalizedNumber(2618646, 'en')).toBe('2,618,646')
|
||||
|
||||
@@ -358,6 +358,24 @@ export const paramsToCacheKey = (params: unknown): string => {
|
||||
return String(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the RFC 4122 canonical 8-4-4-4-12 hex form (case-insensitive).
|
||||
* Accepts any version/variant, since we only need to distinguish "looks like a
|
||||
* UUID" from "does not look like a UUID" (e.g. a template slug).
|
||||
*/
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Checks whether a value is a RFC 4122 canonical-form UUID string.
|
||||
*
|
||||
* Intentionally permissive on version/variant bits: some historical workflows
|
||||
* carry UUIDs generated by older code paths that did not enforce v4, and we
|
||||
* only use this to guard against non-UUID slugs like `"video-point-prompt-example"`.
|
||||
*/
|
||||
export const isValidUuid = (value: unknown): value is string =>
|
||||
typeof value === 'string' && UUID_PATTERN.test(value)
|
||||
|
||||
/**
|
||||
* Generates a RFC4122 compliant UUID v4 using the native crypto API when available
|
||||
* @returns A properly formatted UUID string
|
||||
|
||||
@@ -481,7 +481,7 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
|
||||
it('should reuse the active workflow when loading the same path repeatedly', async () => {
|
||||
const workflowId = 'repeat-workflow-id'
|
||||
const workflowId = '9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
existingWorkflow.changeTracker.activeState.id = workflowId
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
@@ -513,7 +513,8 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
|
||||
it('should reuse active workflow when only one side has an id', async () => {
|
||||
existingWorkflow.changeTracker.activeState.id = 'existing-id'
|
||||
existingWorkflow.changeTracker.activeState.id =
|
||||
'9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
@@ -527,7 +528,7 @@ describe('useWorkflowService', () => {
|
||||
|
||||
it('should reuse active workflow when only workflowData has an id', async () => {
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
id: 'incoming-id',
|
||||
id: '9cea40bb-b0cf-4b40-a758-8935cfe8d52f',
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
@@ -538,7 +539,8 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
|
||||
it('should create new temporary when ids differ', async () => {
|
||||
existingWorkflow.changeTracker.activeState.id = 'existing-id'
|
||||
existingWorkflow.changeTracker.activeState.id =
|
||||
'9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
|
||||
const tempWorkflow = createModeTestWorkflow({
|
||||
path: 'workflows/repeat (2).json'
|
||||
@@ -547,12 +549,62 @@ describe('useWorkflowService', () => {
|
||||
vi.mocked(workflowStore.openWorkflow).mockResolvedValue(tempWorkflow)
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
id: 'different-id',
|
||||
id: '11111111-2222-3333-4444-555555555555',
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(workflowStore.createNewTemporary).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reuse active workflow when the incoming id is a legacy slug and existing id is a fresh UUID', async () => {
|
||||
const existingUuid = '9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
existingWorkflow.changeTracker.activeState.id = existingUuid
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
id: 'video-point-prompt-example',
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(workflowStore.openWorkflow).toHaveBeenCalledWith(existingWorkflow)
|
||||
expect(existingWorkflow.changeTracker.reset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: existingUuid })
|
||||
)
|
||||
expect(existingWorkflow.changeTracker.restore).toHaveBeenCalled()
|
||||
expect(workflowStore.createNewTemporary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reuse active workflow when both sides carry malformed legacy ids', async () => {
|
||||
existingWorkflow.changeTracker.activeState.id = 'legacy-workflow-name'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
id: 'different-legacy-name',
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(workflowStore.openWorkflow).toHaveBeenCalledWith(existingWorkflow)
|
||||
expect(workflowStore.createNewTemporary).not.toHaveBeenCalled()
|
||||
expect(existingWorkflow.changeTracker.reset).toHaveBeenCalled()
|
||||
const resetArg = vi.mocked(existingWorkflow.changeTracker.reset).mock
|
||||
.calls[0]?.[0]
|
||||
expect(resetArg?.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
)
|
||||
expect(resetArg?.id).not.toBe('different-legacy-name')
|
||||
})
|
||||
|
||||
it('should pass through the incoming UUID id to changeTracker.reset on same-path reuse', async () => {
|
||||
const sharedUuid = '9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
existingWorkflow.changeTracker.activeState.id = sharedUuid
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
id: sharedUuid,
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(existingWorkflow.changeTracker.reset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: sharedUuid })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-workflow mode switching', () => {
|
||||
|
||||
@@ -35,7 +35,8 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
appendJsonExt,
|
||||
appendWorkflowJsonExt,
|
||||
generateUUID
|
||||
generateUUID,
|
||||
isValidUuid
|
||||
} from '@/utils/formatUtil'
|
||||
|
||||
function linearModeToAppMode(linearMode: unknown): AppMode | null {
|
||||
@@ -43,6 +44,32 @@ function linearModeToAppMode(linearMode: unknown): AppMode | null {
|
||||
return linearMode ? 'app' : 'graph'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a workflow id for the same-path reuse equality check in
|
||||
* `afterLoadNewGraph`. Non-UUID ids (empty, slug, or otherwise malformed) are
|
||||
* collapsed to `undefined` so that subsequent loads of a file whose stored
|
||||
* legacy id was rewritten by `ensureWorkflowId` still match the active tab
|
||||
* instead of opening a duplicate.
|
||||
*/
|
||||
function normalizeWorkflowIdForReuse(id: unknown): string | undefined {
|
||||
return isValidUuid(id) ? id : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the workflow payload with a UUID-shaped `id`, preserving the
|
||||
* existing one when valid and otherwise substituting `fallbackId` (or a fresh
|
||||
* UUID). Used before passing data to `ChangeTracker.reset` so a slug id on the
|
||||
* incoming payload cannot overwrite the migrated UUID already stored on the
|
||||
* reused workflow.
|
||||
*/
|
||||
function withMigratedId(
|
||||
workflowData: ComfyWorkflowJSON,
|
||||
fallbackId: string | undefined
|
||||
): ComfyWorkflowJSON {
|
||||
if (isValidUuid(workflowData.id)) return workflowData
|
||||
return { ...workflowData, id: fallbackId ?? generateUUID() }
|
||||
}
|
||||
|
||||
export const useWorkflowService = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -460,12 +487,16 @@ export const useWorkflowService = () => {
|
||||
//
|
||||
// This prevents accidental duplicate tabs when startup/load flows
|
||||
// invoke loadGraphData more than once for the same workflow name.
|
||||
const existingId = normalizeWorkflowIdForReuse(
|
||||
existingWorkflow?.activeState?.id
|
||||
)
|
||||
const incomingId = normalizeWorkflowIdForReuse(workflowData.id)
|
||||
const isSameActiveWorkflowLoad =
|
||||
!!existingWorkflow &&
|
||||
workflowStore.isActive(existingWorkflow) &&
|
||||
(existingWorkflow.activeState?.id === undefined ||
|
||||
workflowData.id === undefined ||
|
||||
existingWorkflow.activeState.id === workflowData.id)
|
||||
(existingId === undefined ||
|
||||
incomingId === undefined ||
|
||||
existingId === incomingId)
|
||||
|
||||
if (
|
||||
existingWorkflow &&
|
||||
@@ -483,7 +514,9 @@ export const useWorkflowService = () => {
|
||||
) ?? freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.reset(
|
||||
withMigratedId(workflowData, existingId)
|
||||
)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
return
|
||||
}
|
||||
@@ -504,7 +537,12 @@ export const useWorkflowService = () => {
|
||||
loadedWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.reset(
|
||||
withMigratedId(
|
||||
workflowData,
|
||||
normalizeWorkflowIdForReuse(loadedWorkflow.activeState?.id)
|
||||
)
|
||||
)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ComfyWorkflow,
|
||||
LoadedComfyWorkflow
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
@@ -208,6 +209,74 @@ describe('useWorkflowStore', () => {
|
||||
expect(state.id.length).toBeGreaterThan(0)
|
||||
expect(workflowDataWithoutId.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should replace a non-UUID workflow id with a fresh UUID', () => {
|
||||
const workflowDataWithSlugId = {
|
||||
...defaultGraph,
|
||||
id: 'video-point-prompt-example'
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const workflow = store.createTemporary(
|
||||
'slug-id.json',
|
||||
workflowDataWithSlugId
|
||||
)
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(state.id).not.toBe('video-point-prompt-example')
|
||||
expect(state.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
)
|
||||
expect(workflowDataWithSlugId.id).toBe('video-point-prompt-example')
|
||||
})
|
||||
|
||||
it('should replace an empty-string id with a fresh UUID', () => {
|
||||
const workflowDataWithEmptyId = {
|
||||
...defaultGraph,
|
||||
id: ''
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const workflow = store.createTemporary(
|
||||
'empty-id.json',
|
||||
workflowDataWithEmptyId
|
||||
)
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(state.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve a valid existing UUID', () => {
|
||||
const existingUuid = '9cea40bb-b0cf-4b40-a758-8935cfe8d52f'
|
||||
const workflowDataWithValidId = {
|
||||
...defaultGraph,
|
||||
id: existingUuid
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const workflow = store.createTemporary(
|
||||
'valid-uuid.json',
|
||||
workflowDataWithValidId
|
||||
)
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(state.id).toBe(existingUuid)
|
||||
})
|
||||
|
||||
it('should preserve a valid uppercase UUID', () => {
|
||||
const existingUuid = '9CEA40BB-B0CF-4B40-A758-8935CFE8D52F'
|
||||
const workflowDataWithUpperId = {
|
||||
...defaultGraph,
|
||||
id: existingUuid
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const workflow = store.createTemporary(
|
||||
'upper-uuid.json',
|
||||
workflowDataWithUpperId
|
||||
)
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(state.id).toBe(existingUuid)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWorkflow', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { generateUUID, getPathDetails } from '@/utils/formatUtil'
|
||||
import { generateUUID, getPathDetails, isValidUuid } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
import { ComfyWorkflow } from './comfyWorkflow'
|
||||
@@ -263,7 +263,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
? (JSON.parse(JSON.stringify(workflowData)) as ComfyWorkflowJSON)
|
||||
: (JSON.parse(defaultGraphJSON) as ComfyWorkflowJSON)
|
||||
|
||||
if (!base.id) {
|
||||
if (!isValidUuid(base.id)) {
|
||||
base.id = generateUUID()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user