mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
Backport of #10679. Conflicts resolved same as core/1.42. Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
This commit is contained in:
341
src/components/builder/useBuilderSave.test.ts
Normal file
341
src/components/builder/useBuilderSave.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
|
||||
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
|
||||
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
|
||||
const mockSaveWorkflowAs = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<boolean | null>>()
|
||||
)
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
filename: string
|
||||
initialMode?: string | null
|
||||
} | null>(null)
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackEnterLinear: mockTrackEnterLinear,
|
||||
trackDefaultViewSet: mockTrackDefaultViewSet
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: mockSaveWorkflow,
|
||||
saveWorkflowAs: mockSaveWorkflowAs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: mockCloseDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: mockShowConfirmDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) return `${key}:${JSON.stringify(params)}`
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderSaveDialogContent.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
describe('useBuilderSave', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
})
|
||||
|
||||
describe('save()', () => {
|
||||
it('does nothing when there is no active workflow', async () => {
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves workflow directly without showing a dialog', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflow.mockResolvedValueOnce(undefined)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts error on failure', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const error = new Error('save failed')
|
||||
mockSaveWorkflow.mockRejectedValueOnce(error)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents concurrent saves', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
let resolveSave!: () => void
|
||||
mockSaveWorkflow.mockReturnValueOnce(
|
||||
new Promise<void>((r) => {
|
||||
resolveSave = r
|
||||
})
|
||||
)
|
||||
const { save, isSaving } = useBuilderSave()
|
||||
|
||||
const firstSave = save()
|
||||
expect(isSaving.value).toBe(true)
|
||||
|
||||
await save()
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSave()
|
||||
await firstSave
|
||||
expect(isSaving.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs()', () => {
|
||||
it('does nothing when there is no active workflow', () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
|
||||
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(key).toBe(SAVE_DIALOG_KEY)
|
||||
expect(props.defaultFilename).toBe('my-workflow')
|
||||
expect(props.defaultOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes defaultOpenAsApp: false when initialMode is graph', () => {
|
||||
mockActiveWorkflow.value = {
|
||||
filename: 'my-workflow',
|
||||
initialMode: 'graph'
|
||||
}
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
const { props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(props.defaultOpenAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save dialog callbacks', () => {
|
||||
function getSaveDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
return mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onSave calls saveWorkflowAs with isApp and tracks telemetry', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name',
|
||||
isApp: true
|
||||
}
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave passes isApp: false when saving as graph', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name',
|
||||
isApp: false
|
||||
}
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave does not track or close when saveWorkflowAs returns falsy', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(null)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockTrackDefaultViewSet).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onSave closes dialog and shows success dialog after successful save', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
|
||||
})
|
||||
|
||||
it('shows app success message when openAsApp is true', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
|
||||
})
|
||||
|
||||
it('shows graph success message with exit builder button when openAsApp is false', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
|
||||
expect(successCall.footerProps.confirmText).toBe(
|
||||
'linearMode.builder.exit'
|
||||
)
|
||||
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
|
||||
})
|
||||
|
||||
it('onSave toasts error and closes dialog on failure', async () => {
|
||||
const error = new Error('save-as failed')
|
||||
mockSaveWorkflowAs.mockRejectedValueOnce(error)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
})
|
||||
|
||||
it('prevents concurrent handleSaveAs calls', async () => {
|
||||
let resolveSaveAs!: (v: boolean) => void
|
||||
mockSaveWorkflowAs.mockReturnValueOnce(
|
||||
new Promise<boolean>((r) => {
|
||||
resolveSaveAs = r
|
||||
})
|
||||
)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
const firstSave = onSave('new-name', true)
|
||||
|
||||
await onSave('other-name', true)
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSaveAs(true)
|
||||
await firstSave
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph success dialog callbacks', () => {
|
||||
async function getGraphSuccessDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
}
|
||||
await onSave('new-name', false)
|
||||
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onConfirm closes dialog and exits builder', async () => {
|
||||
const { onConfirm } = await getGraphSuccessDialogProps()
|
||||
|
||||
onConfirm()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onCancel closes dialog and switches to app mode', async () => {
|
||||
const { onCancel } = await getGraphSuccessDialogProps()
|
||||
|
||||
onCancel()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'app_builder'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
135
src/components/builder/useBuilderSave.ts
Normal file
135
src/components/builder/useBuilderSave.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { setMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function closeDialog(key: string) {
|
||||
dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename: workflow.filename,
|
||||
defaultOpenAsApp: workflow.initialMode !== 'graph',
|
||||
onSave: handleSaveAs,
|
||||
onClose: () => closeDialog(SAVE_DIALOG_KEY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveAs(filename: string, openAsApp: boolean) {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
isApp: openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
showSuccessDialog(openAsApp ? 'app' : 'graph')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(viewType: 'app' | 'graph') {
|
||||
const promptText =
|
||||
viewType === 'app'
|
||||
? t('builderSave.successBodyApp')
|
||||
: t('builderSave.successBodyGraph')
|
||||
|
||||
showConfirmDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
headerProps: {
|
||||
title: t('builderSave.successTitle'),
|
||||
icon: 'icon-[lucide--circle-check-big] text-green-500'
|
||||
},
|
||||
props: { promptText, preserveNewlines: true },
|
||||
footerProps:
|
||||
viewType === 'graph'
|
||||
? {
|
||||
cancelText: t('builderToolbar.viewApp'),
|
||||
confirmText: t('linearMode.builder.exit'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
}
|
||||
: {
|
||||
cancelText: t('g.close'),
|
||||
confirmText: t('builderToolbar.viewApp'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { save, saveAs, isSaving }
|
||||
}
|
||||
@@ -37,8 +37,6 @@ export function useAppMode() {
|
||||
)
|
||||
|
||||
function setMode(newMode: AppMode) {
|
||||
if (newMode === mode.value) return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.activeMode = newMode
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ vi.mock('@/services/dialogService', () => ({
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { ds: { offset: [0, 0], scale: 1 } },
|
||||
rootGraph: { serialize: vi.fn(() => ({})) },
|
||||
rootGraph: { serialize: vi.fn(() => ({})), extra: {} },
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -97,7 +97,11 @@ vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => ({
|
||||
trackDefaultViewSet: vi.fn(),
|
||||
trackWorkflowSaved: vi.fn(),
|
||||
trackEnterLinear: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
|
||||
@@ -317,48 +321,6 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflowAs', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
workflowStore = useWorkflowStore()
|
||||
})
|
||||
|
||||
it('should rename then save when workflow is temporary', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/Unsaved Workflow.json'
|
||||
})
|
||||
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
|
||||
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
|
||||
vi.mocked(workflowStore.renameWorkflow).mockResolvedValue()
|
||||
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
|
||||
|
||||
const result = await useWorkflowService().saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow'
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('should return false when no filename is provided', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/test.json'
|
||||
})
|
||||
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
|
||||
|
||||
const result = await useWorkflowService().saveWorkflowAs(workflow)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('afterLoadNewGraph', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let existingWorkflow: LoadedComfyWorkflow
|
||||
@@ -527,6 +489,20 @@ describe('useWorkflowService', () => {
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
expect(appMode.mode.value).toBe('builder:arrange')
|
||||
})
|
||||
|
||||
it('sets activeMode even when initialMode already matches', () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
initialMode: 'app',
|
||||
activeMode: null
|
||||
})
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
// mode.value is 'app' via initialMode fallback, but activeMode
|
||||
// must still be set so the UI transitions to app view
|
||||
appMode.setMode('app')
|
||||
|
||||
expect(workflow.activeMode).toBe('app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('afterLoadNewGraph initializes initialMode', () => {
|
||||
@@ -675,6 +651,7 @@ describe('useWorkflowService', () => {
|
||||
service = useWorkflowService()
|
||||
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
function createTemporaryWorkflow(
|
||||
@@ -692,6 +669,34 @@ describe('useWorkflowService', () => {
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
it('should rename then save when workflow is temporary', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
|
||||
|
||||
const result = await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow'
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('should return false when no filename is provided', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/test.json'
|
||||
})
|
||||
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
|
||||
|
||||
const result = await service.saveWorkflowAs(workflow)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends .app.json extension when initialMode is app', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
@@ -726,6 +731,211 @@ describe('useWorkflowService', () => {
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses isApp option over initialMode when provided (graph -> app)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'graph'
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.app.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses isApp option over initialMode when provided (app -> graph)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a copy when saving same name with different mode (not self-overwrite)', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
// Different extension means different path, so it's not a self-overwrite
|
||||
// — a new copy is created instead of modifying the source in place
|
||||
expect(source.initialMode).toBe('graph')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/test.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
it('self-overwrites when saving same name with same mode', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(source)
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
// Same path → self-overwrite: saves in place via saveWorkflow, no copy
|
||||
expect(workflowStore.saveAs).not.toHaveBeenCalled()
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(source)
|
||||
})
|
||||
|
||||
it('does not modify source workflow mode when saving persisted workflow as different mode', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'copy',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(source.initialMode).toBe('graph')
|
||||
expect(copy.initialMode).toBe('app')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/copy.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
it('does not modify source workflow mode when saving app as graph', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'copy',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(source.initialMode).toBe('app')
|
||||
expect(copy.initialMode).toBe('graph')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/copy.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
function captureLinearModeAtSaveTime() {
|
||||
let value: boolean | undefined
|
||||
vi.mocked(workflowStore.saveWorkflow).mockImplementation(async () => {
|
||||
value = app.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
return () => value
|
||||
}
|
||||
|
||||
it('sets linearMode in graph data before saving (graph -> app)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'graph'
|
||||
app.rootGraph.extra = { linearMode: false }
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(true)
|
||||
})
|
||||
|
||||
it('sets linearMode in graph data before saving (app -> graph)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
app.rootGraph.extra = { linearMode: true }
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(false)
|
||||
})
|
||||
|
||||
it('sets linearMode before saving persisted workflow copy', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
app.rootGraph.extra = { linearMode: false }
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'original',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not change initialMode when isApp is omitted (persisted copy)', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
// Real saveAs copies initialMode from source; replicate that here
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, { filename: 'copy' })
|
||||
|
||||
// saveWorkflowAs should not change initialMode when isApp is omitted
|
||||
expect(copy.initialMode).toBe('app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflow', () => {
|
||||
|
||||
@@ -114,12 +114,12 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const saveWorkflowAs = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { filename?: string } = {}
|
||||
options: { filename?: string; isApp?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const newFilename = options.filename ?? (await workflow.promptSave())
|
||||
if (!newFilename) return false
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const isApp = options.isApp ?? workflow.initialMode === 'app'
|
||||
const newPath =
|
||||
workflow.directory + '/' + appendWorkflowJsonExt(newFilename, isApp)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
@@ -136,17 +136,27 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
let target: ComfyWorkflow
|
||||
if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
target = workflow
|
||||
} else {
|
||||
target = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(target)
|
||||
}
|
||||
|
||||
if (options.isApp !== undefined) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
|
||||
@@ -282,6 +282,7 @@ const zExtra = z
|
||||
workflowRendererVersion: zRendererType.optional(),
|
||||
BlueprintDescription: z.string().optional(),
|
||||
BlueprintSearchAliases: z.array(z.string()).optional(),
|
||||
linearMode: z.boolean().optional(),
|
||||
linearData: z
|
||||
.object({
|
||||
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
|
||||
|
||||
Reference in New Issue
Block a user