Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Brown
a8aa1ec5b0 Merge branch 'main' into glary/fix-logout-unsaved-changes-modal 2026-04-10 01:08:06 -07:00
Alexander Brown
17314a6892 Merge branch 'main' into glary/fix-logout-unsaved-changes-modal 2026-04-08 12:45:20 -07:00
Glary-Bot
f98af98733 test: assert no success toast on cancelled logout 2026-04-07 22:53:22 +00:00
Glary-Bot
e22cc1cb95 fix: use exact match for Save button in E2E tests
The 'Don't Save' button rename caused getByRole('button', { name: 'Save' })
to match both 'Save' and 'Don't Save' (Playwright uses substring matching).
Add exact: true to disambiguate.
2026-04-07 22:46:02 +00:00
Glary-Bot
56536ce52b test: add save-before-logout ordering assertion and failed-save scenario 2026-04-07 21:47:47 +00:00
Glary-Bot
b2becca33d fix: handle Save/Don't Save options in logout unsaved changes dialog
- Fix logout to properly handle 3-state dirtyClose response (save/don't save/cancel)
- Save all modified workflows before logout when user clicks Save
- Rename 'No' button to 'Don't Save' in dirtyClose dialog for clarity
- Update logout dialog message to match new options
2026-04-07 21:31:48 +00:00
11 changed files with 186 additions and 470 deletions

2
.gitignore vendored
View File

@@ -99,4 +99,4 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp.glary/

View File

@@ -20,7 +20,7 @@ export class ConfirmDialog {
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
this.save = this.root.getByRole('button', { name: 'Save' })
this.save = this.root.getByRole('button', { name: 'Save', exact: true })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {

View File

@@ -450,7 +450,10 @@ test.describe('Workflow Persistence', () => {
})
// Click "Save" in the dirty close dialog
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
const saveButton = comfyPage.page.getByRole('button', {
name: 'Save',
exact: true
})
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
await comfyPage.workflow.waitForWorkflowIdle()

View File

@@ -86,7 +86,7 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ $t('g.no') }}
{{ $t('g.dontSave') }}
</Button>
<Button @click="onConfirm">
<i class="pi pi-save" />

View File

@@ -0,0 +1,163 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthActions } from '@/composables/auth/useAuthActions'
const {
mockConfirm,
mockAuthLogout,
mockSaveWorkflow,
mockModifiedWorkflows,
mockToastAdd
} = vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockAuthLogout: vi.fn(),
mockSaveWorkflow: vi.fn(),
mockModifiedWorkflows: { value: [] as Array<{ path: string }> },
mockToastAdd: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm,
showSignInDialog: vi.fn()
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
logout: mockAuthLogout
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get modifiedWorkflows() {
return mockModifiedWorkflows.value
}
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
saveWorkflow: mockSaveWorkflow
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: false }
})
}))
describe('useAuthActions', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
mockModifiedWorkflows.value = []
mockAuthLogout.mockResolvedValue(undefined)
mockSaveWorkflow.mockResolvedValue(undefined)
})
describe('logout', () => {
it('should log out directly when no modified workflows exist', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockAuthLogout).toHaveBeenCalledOnce()
})
it('should show unsaved changes dialog when modified workflows exist', async () => {
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
mockConfirm.mockResolvedValue(false)
const { logout } = useAuthActions()
await logout()
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({ type: 'dirtyClose' })
)
})
it('should cancel logout when user closes the dialog', async () => {
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
mockConfirm.mockResolvedValue(null)
const { logout } = useAuthActions()
await logout()
expect(mockAuthLogout).not.toHaveBeenCalled()
expect(mockSaveWorkflow).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('should save all modified workflows then log out when user clicks Save', async () => {
const workflows = [{ path: 'workflow1.json' }, { path: 'workflow2.json' }]
mockModifiedWorkflows.value = workflows
mockConfirm.mockResolvedValue(true)
const { logout } = useAuthActions()
await logout()
expect(mockSaveWorkflow).toHaveBeenCalledTimes(2)
expect(mockSaveWorkflow).toHaveBeenCalledWith(workflows[0])
expect(mockSaveWorkflow).toHaveBeenCalledWith(workflows[1])
expect(mockAuthLogout).toHaveBeenCalledOnce()
const lastSaveOrder = Math.max(
...mockSaveWorkflow.mock.invocationCallOrder
)
const logoutOrder = mockAuthLogout.mock.invocationCallOrder[0]
expect(lastSaveOrder).toBeLessThan(logoutOrder)
})
it('should not log out when saving a workflow fails', async () => {
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
mockConfirm.mockResolvedValue(true)
mockSaveWorkflow.mockRejectedValueOnce(new Error('Save failed'))
const { logout } = useAuthActions()
await logout()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
expect(mockAuthLogout).not.toHaveBeenCalled()
})
it("should log out without saving when user clicks Don't Save", async () => {
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
mockConfirm.mockResolvedValue(false)
const { logout } = useAuthActions()
await logout()
expect(mockSaveWorkflow).not.toHaveBeenCalled()
expect(mockAuthLogout).toHaveBeenCalledOnce()
})
it('should show success toast after logout', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})
})

View File

@@ -9,6 +9,7 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -60,7 +61,16 @@ export const useAuthActions = () => {
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
})
if (!confirmed) return
if (confirmed === null) return
if (confirmed === true) {
const workflowService = useWorkflowService()
const toSave = [...workflowStore.modifiedWorkflows]
for (const workflow of toSave) {
await workflowService.saveWorkflow(workflow)
}
}
}
await authStore.logout()

View File

@@ -129,6 +129,7 @@
"saveAnyway": "Save Anyway",
"saving": "Saving",
"no": "No",
"dontSave": "Don't Save",
"cancel": "Cancel",
"close": "Close",
"closeDialog": "Close dialog",
@@ -2173,7 +2174,7 @@
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Would you like to save before signing out?"
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -1,7 +1,6 @@
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -332,12 +331,9 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
handler(
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
)
// Flush the RAF so the batched update is applied immediately
vi.advanceTimersByTime(16)
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -345,10 +341,6 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
it('should retain entries below the limit', () => {
for (let i = 0; i < 5; i++) {
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
@@ -703,309 +695,3 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - RAF batching', () => {
let store: ReturnType<typeof useExecutionStore>
function getRegisteredHandler(eventName: string) {
const calls = vi.mocked(api.addEventListener).mock.calls
const call = calls.find(([name]) => name === eventName)
return call?.[1] as (e: CustomEvent) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
describe('handleProgress', () => {
function makeProgressEvent(value: number, max: number): CustomEvent {
return new CustomEvent('progress', {
detail: { value, max, prompt_id: 'job-1', node: '1' }
})
}
it('batches multiple progress events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
handler(makeProgressEvent(5, 10))
handler(makeProgressEvent(9, 10))
expect(store._executingNodeProgress).toBeNull()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual({
value: 9,
max: 10,
prompt_id: 'job-1',
node: '1'
})
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(3, 10))
expect(store._executingNodeProgress).toBeNull()
})
it('allows a new batch after the previous RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 1 })
)
handler(makeProgressEvent(7, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 7 })
)
})
})
describe('handleProgressState', () => {
function makeProgressStateEvent(
nodeId: string,
state: string,
value = 0,
max = 10
): CustomEvent {
return new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
[nodeId]: {
value,
max,
state,
node_id: nodeId,
prompt_id: 'job-1',
display_node_id: nodeId
}
}
}
})
}
it('batches multiple progress_state events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running', 1))
handler(makeProgressStateEvent('1', 'running', 5))
handler(makeProgressStateEvent('1', 'running', 9))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
vi.advanceTimersByTime(16)
expect(store.nodeProgressStates['1']).toEqual(
expect.objectContaining({ value: 9, state: 'running' })
)
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running'))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
describe('pending RAF is discarded when execution completes', () => {
it('discards pending progress RAF on execution_success', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress_state RAF on execution_success', () => {
const progressStateHandler = getRegisteredHandler('progress_state')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressStateHandler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
it('discards pending progress RAF on execution_error', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const errorHandler = getRegisteredHandler('execution_error')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
errorHandler(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
exception_message: 'error',
exception_type: 'RuntimeError',
traceback: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress RAF on execution_interrupted', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const interruptedHandler = getRegisteredHandler('execution_interrupted')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
interruptedHandler(
new CustomEvent('execution_interrupted', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
executed: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
})
describe('unbindExecutionEvents cancels pending RAFs', () => {
it('cancels pending progress RAF on unbind', () => {
const handler = getRegisteredHandler('progress')
handler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('cancels pending progress_state RAF on unbind', () => {
const handler = getRegisteredHandler('progress_state')
handler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 0,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
})

View File

@@ -33,7 +33,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import { createRafCoalescer } from '@/utils/rafBatch'
interface QueuedJob {
/**
@@ -243,8 +242,6 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
cancelPendingProgressUpdates()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -293,10 +290,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Cancel any pending progress RAF before clearing state to prevent
// stale data from being written back on the next frame.
progressCoalescer.cancel()
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -339,15 +332,8 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = pruned
}
const progressStateCoalescer =
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
progressStateCoalescer.push(e.detail)
}
function _applyProgressState(detail: ProgressStateWsMessage) {
const { nodes, prompt_id: jobId } = detail
const { nodes, prompt_id: jobId } = e.detail
// Revoke previews for nodes that are starting to execute
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
@@ -383,17 +369,8 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
_executingNodeProgress.value = detail
})
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
progressCoalescer.push(e.detail)
}
function cancelPendingProgressUpdates() {
progressCoalescer.cancel()
progressStateCoalescer.cancel()
_executingNodeProgress.value = e.detail
}
function handleStatus() {
@@ -515,8 +492,6 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
cancelPendingProgressUpdates()
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null

View File

@@ -1,85 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafCoalescer } from '@/utils/rafBatch'
describe('createRafCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('applies the latest pushed value on the next frame', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.push(2)
coalescer.push(3)
expect(apply).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(3)
})
it('does not apply after cancel', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(42)
coalescer.cancel()
vi.advanceTimersByTime(16)
expect(apply).not.toHaveBeenCalled()
})
it('applies immediately on flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(99)
coalescer.flush()
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(99)
})
it('does nothing on flush when no value is pending', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.flush()
expect(apply).not.toHaveBeenCalled()
})
it('does not double-apply after flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.flush()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
})
it('reports scheduled state correctly', () => {
const coalescer = createRafCoalescer<number>(vi.fn())
expect(coalescer.isScheduled()).toBe(false)
coalescer.push(1)
expect(coalescer.isScheduled()).toBe(true)
vi.advanceTimersByTime(16)
expect(coalescer.isScheduled()).toBe(false)
})
})

View File

@@ -27,40 +27,3 @@ export function createRafBatch(run: () => void) {
return { schedule, cancel, flush, isScheduled }
}
/**
* Last-write-wins RAF coalescer. Buffers the latest value and applies it
* on the next animation frame, coalescing multiple pushes into a single
* reactive update.
*/
export function createRafCoalescer<T>(apply: (value: T) => void) {
let hasPending = false
let pendingValue: T | undefined
const batch = createRafBatch(() => {
if (!hasPending) return
const value = pendingValue as T
hasPending = false
pendingValue = undefined
apply(value)
})
const push = (value: T) => {
pendingValue = value
hasPending = true
batch.schedule()
}
const cancel = () => {
hasPending = false
pendingValue = undefined
batch.cancel()
}
const flush = () => {
if (!hasPending) return
batch.flush()
}
return { push, cancel, flush, isScheduled: batch.isScheduled }
}