Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
79314b233c chore: disable CodeRabbit docstring coverage pre-merge check
The docstring coverage check is enabled via CodeRabbit organization
settings and currently blocks merges. Override it at the repo level by
setting reviews.pre_merge_checks.docstrings.mode to 'off', which takes
precedence over org settings without affecting other org repos.
2026-06-19 15:29:01 -07:00
57 changed files with 267 additions and 2078 deletions

View File

@@ -15,6 +15,11 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -41,6 +42,7 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -8,7 +8,7 @@ export class BaseDialog {
public readonly page: Page,
testId?: string
) {
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
this.closeButton = this.root.getByRole('button', { name: 'Close' })
}

View File

@@ -36,11 +36,9 @@ export class BuilderSaveAsHelper {
this.closeButton = this.successDialog
.getByRole('button', { name: 'Close', exact: true })
.filter({ hasText: 'Close' })
// The icon-only X carries an aria-label, while the footer Close button
// is named by its text — getByLabel only matches the former.
this.dismissButton = this.successDialog.getByLabel('Close', {
exact: true
})
this.dismissButton = this.successDialog.locator(
'button.p-dialog-close-button'
)
this.exitBuilderButton = this.successDialog.getByRole('button', {
name: 'Exit builder'
})

View File

@@ -231,22 +231,6 @@ export class ExecutionHelper {
)
}
/** Send `execution_interrupted` WS event (user-initiated stop). */
executionInterrupted(jobId: string, nodeId: string): void {
this.requireWs().send(
JSON.stringify({
type: 'execution_interrupted',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
executed: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.requireWs().send(

View File

@@ -38,6 +38,7 @@ export const TestIds = {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',

View File

@@ -99,15 +99,15 @@ async function mockShareableAssets(
}
/**
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
* auth-triggered modals by pressing Escape until they clear.
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const dialogs = page.getByRole('dialog')
const mask = page.locator('.p-dialog-mask')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await dialogs.count()) === 0) break
if ((await mask.count()) === 0) break
await page.keyboard.press('Escape')
await dialogs
await mask
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})

View File

@@ -612,23 +612,18 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
comfyPage
}) => {
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
// around each individual gesture. Holding the modifiers down across all
// three drags plus the intervening screenshot assertions could saturate the
// main thread and stall a single mouse.move step past the test timeout, and
// a mid-test failure would leave the modifiers stuck down. Releasing per
// gesture matches the robust pattern used in canvasSettings.spec.ts.
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.canvasOps.ctrlShiftDrag(
{ x: 10, y: 280 },
{ x: 10, y: 220 }
)
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('Shift')
})
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({

View File

@@ -1,139 +0,0 @@
import type { Locator, WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
async function runOnBackgroundTab(
comfyPage: ComfyPage,
ws: WebSocketRoute
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
const topbar = comfyPage.menu.topbar
await comfyPage.workflow.waitForActiveWorkflow()
await comfyPage.workflow.waitForWorkflowIdle()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
await comfyPage.nextFrame()
await topbar.newWorkflowButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(topbar.getActiveTab()).toContainText('(2)')
const backgroundTab = topbar.getTab(0)
exec.executionStart(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toBeVisible()
return { exec, jobId, backgroundTab }
}
test.describe('Workflow tab status indicator', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('replaces the running indicator with completed when the job finishes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toHaveCount(0)
})
test('shows failed when the background job errors', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
// The error opens a modal dialog that aria-hides the rest of the app
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
// so the badge is reachable by role.
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(errorDialog).toBeHidden()
await expect(
backgroundTab.getByRole('img', { name: 'Failed' })
).toBeVisible()
})
test('drops the indicator on user interrupt rather than showing an error', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionInterrupted(jobId, KSAMPLER_NODE)
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
test('clears the indicator once the tab is activated', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
const currentTab = comfyPage.menu.topbar.getActiveTab()
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await backgroundTab.click()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
await currentTab.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
})

View File

@@ -44,30 +44,14 @@ describe('GlobalDialog renderer branching', () => {
cleanup()
})
it('renders the Reka branch when renderer is omitted (default)', async () => {
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'renderer-default',
title: 'Default renderer dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-escape-hatch',
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body,
dialogComponentProps: { renderer: 'primevue' }
component: Body
})
const dialogs = await screen.findAllByRole('dialog')

View File

@@ -1,54 +0,0 @@
/**
* Dialog migration regression net: the showConfirmDialog helper must open
* its dialog through the Reka renderer with zeroed section padding (the
* Confirm* sections carry their own). Catches accidental reverts of the
* Phase 6 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
describe('showConfirmDialog Reka renderer opt-in', () => {
beforeEach(() => {
showDialog.mockReset()
})
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
showConfirmDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('md')
expect(args.dialogComponentProps.headerClass).toBe('p-0')
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
expect(args.dialogComponentProps.footerClass).toBe('p-0')
expect(args.dialogComponentProps.pt).toBeUndefined()
})
it('forwards the confirm section components and caller props', () => {
showConfirmDialog({
key: 'confirm-test',
headerProps: { title: 'Title' },
props: { promptText: 'Prompt' },
footerProps: { confirmText: 'Delete' }
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('confirm-test')
expect(args.headerComponent).toBe(ConfirmHeader)
expect(args.component).toBe(ConfirmBody)
expect(args.footerComponent).toBe(ConfirmFooter)
expect(args.headerProps).toEqual({ title: 'Title' })
expect(args.props).toEqual({ promptText: 'Prompt' })
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
})
})

View File

@@ -1,7 +1,6 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -12,9 +11,7 @@ interface ConfirmDialogOptions {
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(
options: ConfirmDialogOptions = {}
): DialogInstance {
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({
@@ -26,13 +23,11 @@ export function showConfirmDialog(
props,
footerProps,
dialogComponentProps: {
renderer: 'reka',
size: 'md',
// Confirm sections carry their own padding — zero out the dialog
// chrome padding, like the PrimeVue `pt` overrides did.
headerClass: 'p-0',
bodyClass: 'p-0',
footerClass: 'p-0'
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -1,233 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { markRaw } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type * as ExecutionStoreModule from '@/stores/executionStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
new Map()
),
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isInitialized: true
})
}))
vi.mock('@/stores/executionStore', async (importOriginal) => {
const actual = await importOriginal<typeof ExecutionStoreModule>()
return {
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
useExecutionStore: () => ({
getWorkflowStatus(workflow: object | undefined | null) {
if (!workflow) return undefined
return mockWorkflowStatus.value.get(workflow)
}
})
}
})
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDraggable: vi.fn(),
usePragmaticDroppable: vi.fn()
}))
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
useWorkflowActionsMenu: () => ({
menuItems: { value: [] }
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
closeWorkflow: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: {
render: () => null,
methods: {
showPopover: () => {},
hidePopover: () => {},
togglePopover: () => {}
}
}
}))
import WorkflowTab from './WorkflowTab.vue'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
type WorkflowOption = WorkflowTabProps['workflowOption']
type Workflow = WorkflowOption['workflow']
type WorkflowOverrides = Partial<Workflow>
// ComfyWorkflow has many required fields the component never reads (file
// IO, change tracking). Validate the fields we *do* set against the real
// type via Partial<Workflow>, then cast — adding/renaming a read field in
// the component will fail typecheck on the override map.
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
const workflow = {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
activeMode: 'graph',
changeTracker: null,
...overrides
} satisfies WorkflowOverrides
// markRaw keeps a stable identity through prop reactivity so the store's
// identity-based status lookup resolves against the same object.
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
}
function renderTab({
workflowOption = makeWorkflowOption(),
activeWorkflowKey = 'other-key'
}: {
workflowOption?: WorkflowOption
activeWorkflowKey?: string
} = {}) {
return render(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption,
isFirst: false,
isLast: false
}
})
}
describe('WorkflowTab - workflow status indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
})
it.for(['running', 'completed', 'failed'] as const)(
'labels the %s indicator with a translated status name',
(status) => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels[status] })
).toBeTruthy()
}
)
it('does not badge the active tab with its own status', () => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
expect(screen.queryByRole('img')).toBeNull()
})
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('shows the unsaved dot when modified and autosave is off', () => {
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('workflow status replaces the unsaved dot', () => {
const workflowOption = makeWorkflowOption({ isPersisted: false })
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels.running })
).toBeTruthy()
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
})
})
describe('WorkflowTab - close button', () => {
beforeEach(() => {
mockCloseWorkflow.mockClear()
})
it('delegates close to workflow service with the tab workflow', async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByTestId('close-workflow-button'))
expect(mockCloseWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ key: 'test-key' }),
expect.anything()
)
})
})

View File

@@ -21,19 +21,8 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="workflowStatus"
role="img"
:aria-label="workflowStatusLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
workflowStatusIconClasses[workflowStatus]
)
"
/>
<span
v-else-if="shouldShowUnsavedIndicator"
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
@@ -43,7 +32,6 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
data-testid="close-workflow-button"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
@@ -97,14 +85,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
import {
useExecutionStore,
WORKFLOW_STATUS_I18N_KEYS
} from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@comfyorg/tailwind-utils'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -131,7 +113,6 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
@@ -144,7 +125,7 @@ const autoSaveDelay = computed(() =>
settingStore.get('Comfy.Workflow.AutoSaveDelay')
)
const shouldShowUnsavedIndicator = computed(() => {
const shouldShowStatusIndicator = computed(() => {
if (workspaceStore.shiftDown) {
// Branch 1: Shift key is held down, do not show the status indicator.
return false
@@ -179,27 +160,6 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
running:
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
completed: 'icon-[lucide--circle-check] text-success-background',
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
}
// The active tab doesn't badge its own status - the user is already looking
// at it. Background tabs surface the recorded execution status.
const workflowStatus = computed(() =>
isActiveTab.value
? undefined
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
)
const workflowStatusLabel = computed(() =>
workflowStatus.value
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
: undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -43,10 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
}))
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
useWorkflowStatusDismissal: vi.fn()
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
isOverflowing: { value: false },

View File

@@ -117,7 +117,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
@@ -146,9 +145,6 @@ const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed
useWorkflowStatusDismissal()
const { flags } = useFeatureFlags()
const isIntegratedTabBar = computed(

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { CSSProperties } from 'vue'
import { ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
@@ -15,14 +15,7 @@ export interface PositionConfig {
scale?: number
}
interface UseAbsolutePositionReturn {
style: Ref<CSSProperties>
updatePosition: (config: PositionConfig) => void
}
export function useAbsolutePosition(
options: { useTransform?: boolean } = {}
): UseAbsolutePositionReturn {
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
const { useTransform = false } = options
const canvasStore = useCanvasStore()

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { CSSProperties } from 'vue'
import { ref } from 'vue'
interface Rect {
@@ -28,26 +28,7 @@ interface ClippingOptions {
margin?: number
}
interface UseDomClippingReturn {
style: Ref<CSSProperties>
updateClipPath: (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => void
}
export function useDomClipping(
options: ClippingOptions = {}
): UseDomClippingReturn {
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options

View File

@@ -1,4 +1,4 @@
import type { ComputedRef, CSSProperties, Ref } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -8,23 +8,10 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
interface UseNodePreviewAndDragReturn {
previewRef: Ref<HTMLElement | null>
isHovered: Ref<boolean>
isDragging: Ref<boolean>
showPreview: ComputedRef<boolean>
nodePreviewStyle: Ref<CSSProperties>
sidebarLocation: ComputedRef<'left' | 'right'>
handleMouseEnter: (e: MouseEvent) => void
handleMouseLeave: () => void
handleDragStart: (e: DragEvent) => void
handleDragEnd: (e: DragEvent) => void
}
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
panelRef?: Ref<HTMLElement | null>
): UseNodePreviewAndDragReturn {
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>

View File

@@ -9,13 +9,19 @@ export const useQueueClearHistoryDialog = () => {
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
renderer: 'reka',
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
// The content draws its own panel — neutralize the chrome box.
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
pt: {
root: {
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
},
content: {
class: 'bg-transparent',
style: 'padding: 0'
}
}
}
})
}

View File

@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
export type ResizeDirection =
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
@@ -17,17 +17,6 @@ export type ResizeDirection =
| 'sw'
| 'se'
export interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
/** Minimum crop width/height in source image pixel space. */
@@ -275,6 +264,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
height: `${cropHeight.value * scaleFactor.value}px`
}))
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
const allResizeHandles = computed<ResizeHandle[]>(() => {

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick } from 'vue'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockActiveWorkflow, statusMap } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockActiveWorkflow: shallowRef<object | null>(null),
statusMap: shallowRef<Map<object, WorkflowExecutionStatus>>(new Map())
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
getWorkflowStatus: (workflow: object | null | undefined) =>
workflow ? statusMap.value.get(workflow) : undefined,
clearWorkflowStatus: (workflow: object) => {
const next = new Map(statusMap.value)
next.delete(workflow)
statusMap.value = next
}
})
}))
import { useWorkflowStatusDismissal } from './useWorkflowStatusDismissal'
const workflowA = { path: '/a.json' }
const workflowB = { path: '/b.json' }
function mount() {
const scope = effectScope()
scope.run(() => useWorkflowStatusDismissal())
return () => scope.stop()
}
describe('useWorkflowStatusDismissal', () => {
beforeEach(() => {
mockActiveWorkflow.value = null
statusMap.value = new Map()
})
it('clears a terminal status when its workflow becomes active', async () => {
statusMap.value = new Map([[workflowA, 'completed']])
const stop = mount()
mockActiveWorkflow.value = workflowA
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
stop()
})
it('clears a terminal status that arrives while the workflow is active', async () => {
mockActiveWorkflow.value = workflowA
const stop = mount()
statusMap.value = new Map([[workflowA, 'failed']])
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
stop()
})
it('keeps a running status on the active workflow', async () => {
mockActiveWorkflow.value = workflowA
const stop = mount()
statusMap.value = new Map([[workflowA, 'running']])
await nextTick()
expect(statusMap.value.get(workflowA)).toBe('running')
stop()
})
it('leaves other workflows untouched', async () => {
statusMap.value = new Map([
[workflowA, 'completed'],
[workflowB, 'completed']
])
const stop = mount()
mockActiveWorkflow.value = workflowA
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
expect(statusMap.value.get(workflowB)).toBe('completed')
stop()
})
})

View File

@@ -1,22 +0,0 @@
import { watch } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
export function useWorkflowStatusDismissal() {
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
executionStore.clearWorkflowStatus(workflow)
}
},
{ immediate: true }
)
}

View File

@@ -36,15 +36,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
options?.afterClose?.()
},
initialCategory
},
// The template browser is a wide layout. Without an explicit size the
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
// filter bar so the Clear Filters button lands outside the viewport.
// Size it like the other large dialogs (Settings/Manager).
dialogComponentProps: {
size: 'full',
contentClass:
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
}
})
}

View File

@@ -154,10 +154,8 @@ export const i18n = createI18n({
})
/** Convenience shorthand: i18n.global */
export const t: (typeof i18n.global)['t'] = i18n.global.t
export const te: (typeof i18n.global)['te'] = i18n.global.te
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm
export const { t, te, d } = i18n.global
const { tm } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -3340,7 +3340,7 @@
"mediaLabel": "{count} Media File | {count} Media Files",
"modelsLabel": "{count} Model | {count} Models",
"checkingAssets": "Checking media visibility…",
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
"acknowledgeCheckbox": "I understand these media items will be published and made public",
"inLibrary": "In library",
"comfyHubTitle": "Upload to ComfyHub",
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",

View File

@@ -5,6 +5,7 @@ import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
@@ -132,6 +133,7 @@ app
}
}
})
.use(ConfirmationService)
.use(ToastService)
.use(pinia)
.use(i18n)

View File

@@ -144,6 +144,7 @@ import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -157,10 +158,7 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import {
getAssetDisplayName,
resolveDisplayImageDimensions
} from '../utils/assetMetadataUtils'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
@@ -281,15 +279,12 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const displayImageDimensions = computed(() =>
resolveDisplayImageDimensions(asset, imageDimensions.value)
)
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)

View File

@@ -1,7 +1,6 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useDialogService } from '@/services/dialogService'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
interface ShowOptions {
@@ -24,10 +23,6 @@ interface BrowseOptions {
}
const DIALOG_KEY = 'global-asset-browser'
const ASSET_BROWSER_DIALOG_PROPS = {
contentClass:
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
} satisfies DialogComponentProps
export const useAssetBrowserDialog = () => {
const dialogService = useDialogService()
@@ -52,8 +47,7 @@ export const useAssetBrowserDialog = () => {
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: hide
},
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
}
})
}
@@ -72,8 +66,7 @@ export const useAssetBrowserDialog = () => {
title: options.title,
onSelect: handleAssetSelected,
onClose: hide
},
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
}
})
}

View File

@@ -774,10 +774,6 @@ export function useMediaAssetActions() {
onCancel: () => {
resolve(false)
}
},
dialogComponentProps: {
renderer: 'reka',
size: 'md'
}
})
})

View File

@@ -13,15 +13,6 @@ import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
// Contents bring their own width and padding — shrink-wrap the chrome and
// zero the section padding (the PrimeVue `pt` overrides this replaces).
const uploadDialogComponentProps = {
renderer: 'reka',
contentClass: 'w-fit max-w-[calc(100vw-1rem)]',
headerClass: 'py-0 pl-0',
bodyClass: 'p-0 overflow-y-hidden'
} as const
export function useModelUpload(
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
@@ -40,7 +31,12 @@ export function useModelUpload(
key: 'upload-model-upgrade',
headerComponent: UploadModelUpgradeModalHeader,
component: UploadModelUpgradeModal,
dialogComponentProps: uploadDialogComponentProps
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0! overflow-y-hidden!'
}
}
})
} else {
dialogStore.showDialog({
@@ -53,7 +49,12 @@ export function useModelUpload(
await onUploadSuccess?.(result)
}
},
dialogComponentProps: uploadDialogComponentProps
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0! overflow-y-hidden!'
}
}
})
}
}

View File

@@ -10,14 +10,12 @@ import {
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName,
resolveDisplayImageDimensions
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -419,124 +417,4 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
describe('getAssetMetadataDimensions', () => {
it('returns dimensions when width/height are positive integers', () => {
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
expect(getAssetMetadataDimensions(asset)).toEqual({
width: 1024,
height: 768
})
})
it.for([
{ name: 'NaN width', width: Number.NaN, height: 768 },
{
name: 'Infinity height',
width: 1024,
height: Number.POSITIVE_INFINITY
},
{ name: 'zero width', width: 0, height: 768 },
{ name: 'negative height', width: 1024, height: -1 },
{ name: 'fractional width', width: 1024.5, height: 768 },
{ name: 'string width', width: '1024', height: 768 },
{ name: 'missing width', width: undefined, height: 768 }
])('returns undefined for invalid shape: $name', ({ width, height }) => {
const asset = { ...mockAsset, metadata: { width, height } }
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
})
it('returns undefined when metadata is absent', () => {
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
})
it('returns undefined when asset itself is undefined', () => {
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
})
})
describe('resolveDisplayImageDimensions', () => {
const rendered = { width: 512, height: 288 }
it('prefers server metadata dimensions over the rendered natural size', () => {
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 1920, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
const asset = { ...mockAsset }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
const fullResUrl =
'http://localhost:8188/view?filename=output.png&type=output'
const asset = {
...mockAsset,
thumbnail_url: fullResUrl,
preview_url: fullResUrl
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 0, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined when neither metadata nor a rendered size is available', () => {
expect(
resolveDisplayImageDimensions(mockAsset, undefined)
).toBeUndefined()
})
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
rendered
)
})
})
})

View File

@@ -216,64 +216,6 @@ export function getAssetCardTitle(asset: AssetItem): string {
return getAssetDisplayFilename(asset)
}
export interface ImageDimensions {
width: number
height: number
}
/**
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
* NaN, Infinity, 0, negatives, and fractional values.
*/
function isValidDimension(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
/**
* Returns the original image dimensions from `asset.metadata.{width,height}`
* when both pass shape validation, otherwise `undefined`. Callers should fall
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
* on runtimes that serve the original file but reports preview size on
* runtimes that serve a downscaled preview.
*/
export function getAssetMetadataDimensions(
asset: AssetItem | undefined
): ImageDimensions | undefined {
const w = asset?.metadata?.width
const h = asset?.metadata?.height
if (isValidDimension(w) && isValidDimension(h)) {
return { width: w, height: h }
}
return undefined
}
/**
* Resolves the image dimensions an asset card should display.
*
* Prefers the server-provided original dimensions from
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
* actually rendered — and only when that rendered image was the original file.
*
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
* preview's dimensions rather than the asset's. In that case this returns
* `undefined` so the card shows no label rather than a wrong resolution.
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
* so the guard correctly passes through `renderedNaturalSize`.
*/
export function resolveDisplayImageDimensions(
asset: AssetItem | undefined,
renderedNaturalSize: ImageDimensions | undefined
): ImageDimensions | undefined {
const fromMetadata = getAssetMetadataDimensions(asset)
if (fromMetadata) return fromMetadata
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
return undefined
return renderedNaturalSize
}
/**
* Returns the filename component the cloud `/api/view` endpoint resolves
* for this asset — `hash` when present (cloud assets are hash-keyed

View File

@@ -49,9 +49,13 @@ export const useSubscriptionDialog = () => {
),
props: { onClose: hide },
dialogComponentProps: {
renderer: 'reka',
contentClass:
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
style: 'width: min(360px, 95vw);',
pt: {
root: {
class: 'bg-transparent border-none rounded-none shadow-none'
},
content: { class: '!p-0 bg-transparent border-none shadow-none' }
}
}
})
return
@@ -85,14 +89,16 @@ export const useSubscriptionDialog = () => {
component,
props: useWorkspaceVariant ? workspaceProps : personalProps,
dialogComponentProps: {
renderer: 'reka',
size: 'full',
// The pricing tables host a PrimeVue Popover teleported to body.
// Reka's modal mode traps focus and disables body pointer-events,
// making the popover unclickable. Mirrors Settings/Manager.
modal: false,
contentClass:
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
root: {
class: 'rounded-2xl bg-transparent h-full'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
}
}
}
})
}
@@ -116,10 +122,16 @@ export const useSubscriptionDialog = () => {
}
},
dialogComponentProps: {
renderer: 'reka',
size: 'full',
contentClass:
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
style: 'width: min(640px, 95vw);',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return

View File

@@ -258,15 +258,15 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures auth events with metadata', async () => {
it('captures events with metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ method: 'google', share_id: 'share-1' })
provider.trackAuth({ method: 'google' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{ method: 'google', share_id: 'share-1' }
{ method: 'google' }
)
})

View File

@@ -66,7 +66,11 @@ export function useShareDialog() {
onClose: hide
},
dialogComponentProps: {
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
pt: {
root: {
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
}
}
}
})
}

View File

@@ -266,7 +266,11 @@ describe('useSharedWorkflowUrlLoader', () => {
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
{ share: 'share-id-1' },
['share']
)
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'share'

View File

@@ -9,13 +9,13 @@ import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import {
capturePreservedQuery,
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { isValidShareId } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -49,6 +49,10 @@ export function useSharedWorkflowUrlLoader() {
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
return /^[a-zA-Z0-9_.-]+$/.test(param)
}
async function ensureShareQueryFromIntent() {
hydratePreservedQuery(SHARE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
@@ -106,7 +110,11 @@ export function useSharedWorkflowUrlLoader() {
},
dialogComponentProps: {
onClose: () => resolve({ action: 'cancel' }),
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
pt: {
root: {
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
}
}
}
})
})
@@ -125,7 +133,7 @@ export function useSharedWorkflowUrlLoader() {
return 'not-present'
}
if (!isValidShareId(shareParam)) {
if (!isValidParameter(shareParam)) {
console.warn(
`[useSharedWorkflowUrlLoader] Invalid share parameter format: ${shareParam}`
)
@@ -144,6 +152,13 @@ export function useSharedWorkflowUrlLoader() {
view_mode: mode.value,
is_app_mode: isAppMode.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ share: shareParam },
['share']
)
}
const result = await showOpenSharedWorkflowDialog(shareParam)

View File

@@ -1,70 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { LocationQuery } from 'vue-router'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import {
isValidShareId,
preserveLoggedOutShareAuthAttribution
} from './shareAuthAttribution'
const SHARE_AUTH_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE_AUTH
const invalidShareQueries: Array<{ name: string; query: LocationQuery }> = [
{ name: 'missing', query: {} },
{ name: 'array value', query: { share: ['share-id-1'] } },
{ name: 'invalid characters', query: { share: '../share-id-1' } }
]
describe('shareAuthAttribution', () => {
beforeEach(() => {
clearPreservedQuery(SHARE_AUTH_NAMESPACE)
sessionStorage.clear()
})
it('preserves a valid share id for logged-out users', () => {
preserveLoggedOutShareAuthAttribution({ share: 'share-id_1.2' }, false)
expect(getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')).toBe(
'share-id_1.2'
)
expect(sessionStorage.getItem('Comfy.PreservedQuery.share_auth')).toContain(
'share-id_1.2'
)
})
it('does not preserve share attribution for logged-in users', () => {
preserveLoggedOutShareAuthAttribution({ share: 'share-id-1' }, true)
expect(
getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')
).toBeUndefined()
expect(sessionStorage.getItem('Comfy.PreservedQuery.share_auth')).toBeNull()
})
it.for(invalidShareQueries)(
'does not preserve $name share params',
({ query }) => {
preserveLoggedOutShareAuthAttribution(query, false)
expect(
getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')
).toBeUndefined()
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
}
)
it.for([
{ shareId: 'abc123', expected: true },
{ shareId: 'share-id_1.2', expected: true },
{ shareId: '../share-id-1', expected: false },
{ shareId: '', expected: false }
])('validates "$shareId" as $expected', ({ shareId, expected }) => {
expect(isValidShareId(shareId)).toBe(expected)
})
})

View File

@@ -1,26 +0,0 @@
import type { LocationQuery } from 'vue-router'
import { capturePreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
const SHARE_QUERY_KEY = 'share'
export function isValidShareId(shareId: string): boolean {
return /^[a-zA-Z0-9_.-]+$/.test(shareId)
}
export function preserveLoggedOutShareAuthAttribution(
query: LocationQuery,
isLoggedIn: boolean
): void {
if (isLoggedIn) return
const shareId = query[SHARE_QUERY_KEY]
if (typeof shareId !== 'string' || !isValidShareId(shareId)) return
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ [SHARE_QUERY_KEY]: shareId },
[SHARE_QUERY_KEY]
)
}

View File

@@ -31,11 +31,6 @@ export interface Member {
email: string
joined_at: string
role: WorkspaceRole
// True when this member is the workspace's original owner/creator
// (member.id == workspace.created_by_user_id). Gates the creator-only
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
}
interface PaginationInfo {

View File

@@ -113,11 +113,7 @@
button-variant="gradient"
/>
<Button
v-if="
showSubscribeAction &&
!isPersonalWorkspace &&
(!isCancelled || permissions.canManageSubscriptionLifecycle)
"
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"

View File

@@ -128,10 +128,9 @@
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: reactivation is original-owner-only. -->
<!-- Cancelled state: show only Resubscribe button -->
<template v-if="isCancelled">
<Button
v-if="permissions.canManageSubscriptionLifecycle"
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
@@ -162,7 +161,7 @@
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan && planMenuItems.length > 0"
v-if="!isFreeTierPlan"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
@@ -514,23 +513,15 @@ const subscriptionTierName = computed(() => {
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
// and the "more options" button is hidden (see template).
const planMenuItems = computed(() =>
permissions.value.canManageSubscriptionLifecycle
? [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
}
]
: []
)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
}
}
])
const tierKey = computed(() => {
const tier = subscriptionTier.value

View File

@@ -82,8 +82,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
name: 'Owner User',
email: 'owner@example.com',
role: 'owner' as const,
joinDate: new Date(0),
isOriginalOwner: true
joinDate: new Date(0)
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
@@ -154,7 +153,6 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -21,7 +21,6 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -111,8 +111,7 @@ export function useMembersPanel() {
name: userDisplayName.value ?? '',
email: userEmail.value ?? '',
role: 'owner' as const,
joinDate: new Date(0),
isOriginalOwner: true
joinDate: new Date(0)
}))
const searchQuery = ref('')

View File

@@ -2,21 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockStore = vi.hoisted(() => ({
activeWorkspace: null as WorkspaceWithRole | null,
isCurrentUserOriginalOwner: false,
ensureMembersLoaded: vi.fn()
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockStore.activeWorkspace
},
get isCurrentUserOriginalOwner() {
return mockStore.isCurrentUserOriginalOwner
},
ensureMembersLoaded: mockStore.ensureMembersLoaded
return mockActiveWorkspace.value
}
})
}))
@@ -52,20 +46,14 @@ async function loadComposable() {
return module.useWorkspaceUI()
}
function resetStore() {
mockStore.activeWorkspace = null
mockStore.isCurrentUserOriginalOwner = false
mockStore.ensureMembersLoaded.mockReset()
}
describe('useWorkspaceUI', () => {
beforeEach(() => {
vi.resetModules()
resetStore()
mockActiveWorkspace.value = null
})
afterEach(() => {
resetStore()
mockActiveWorkspace.value = null
})
describe('when no active workspace', () => {
@@ -83,7 +71,7 @@ describe('useWorkspaceUI', () => {
describe('personal workspace', () => {
beforeEach(() => {
mockStore.activeWorkspace = personalWorkspace
mockActiveWorkspace.value = personalWorkspace
})
it('grants billing access but disables team management', async () => {
@@ -131,7 +119,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as owner', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
})
it('grants full management permissions', async () => {
@@ -171,7 +159,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as member', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamMemberWorkspace
mockActiveWorkspace.value = teamMemberWorkspace
})
it('restricts management actions while allowing leave', async () => {
@@ -207,60 +195,9 @@ describe('useWorkspaceUI', () => {
})
})
// Drives off the members-list self-row original-owner signal, surfaced by the
// store getter `isCurrentUserOriginalOwner`.
describe('subscription lifecycle (creator-only)', () => {
it('grants lifecycle to the personal-workspace sole owner', async () => {
mockStore.activeWorkspace = personalWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('grants lifecycle to a team owner who is the original owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = true
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('fails closed while the members list is still loading', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('withholds lifecycle from members', async () => {
mockStore.activeWorkspace = teamMemberWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('delegates member loading to the store when a team workspace becomes active', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
})
it('does not load members for a personal workspace', async () => {
mockStore.activeWorkspace = personalWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
})
})
describe('shared instance', () => {
it('returns the same composable state for multiple callers within a test', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
const first = await loadComposable()
const second = await loadComposable()

View File

@@ -1,4 +1,4 @@
import { computed, watch } from 'vue'
import { computed } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -14,10 +14,6 @@ interface WorkspacePermissions {
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
// Any owner has `canManageSubscription` (manage payment, top-up, change
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
}
@@ -38,8 +34,7 @@ interface WorkspaceUIConfig {
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole,
isOriginalOwner: boolean
role: WorkspaceRole
): WorkspacePermissions {
if (type === 'personal') {
return {
@@ -51,8 +46,6 @@ function getPermissions(
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
}
}
@@ -67,7 +60,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
}
}
@@ -82,7 +74,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
}
@@ -154,26 +145,8 @@ function useWorkspaceUIInternal() {
() => store.activeWorkspace?.role ?? 'owner'
)
// The original-owner signal lives on the members-list self-row, so a team
// workspace's members must be loaded before its lifecycle gate can resolve.
// The store dedupes in-flight/already-loaded requests and logs failures;
// until members arrive the getter fails closed.
watch(
() => store.activeWorkspace?.id,
() => {
if (store.activeWorkspace?.type === 'team') {
void store.ensureMembersLoaded()
}
},
{ immediate: true }
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(
workspaceType.value,
workspaceRole.value,
store.isCurrentUserOriginalOwner
)
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>

View File

@@ -29,15 +29,6 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock current user (drives the original-owner self-row match by email)
const mockCurrentUser = vi.hoisted(() => ({
userEmail: { value: null as string | null }
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
}))
// Mock workspaceApi
const mockWorkspaceApi = vi.hoisted(() => ({
list: vi.fn(),
@@ -131,7 +122,6 @@ describe('useTeamWorkspaceStore', () => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()
mockCurrentUser.userEmail.value = null
// Reset workspaceAuthStore mock state
mockWorkspaceAuthStore.currentWorkspace = null
@@ -690,193 +680,6 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('ensureMembersLoaded', () => {
const memberRow = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z'
}
function mockMembersResponse() {
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [memberRow],
pagination: { offset: 0, limit: 50, total: 1 }
})
}
async function activateTeamWorkspace() {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
return store
}
it('loads members for a team workspace that is not yet loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
expect(store.members).toHaveLength(1)
})
it('does not load members again once loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('dedupes concurrent calls into a single request', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await Promise.all([
store.ensureMembersLoaded(),
store.ensureMembersLoaded()
])
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('does not load members for a personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('logs a failed request and retries on the next call', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(consoleError).toHaveBeenCalled()
expect(store.members).toHaveLength(0)
mockMembersResponse()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
expect(store.members).toHaveLength(1)
consoleError.mockRestore()
})
})
describe('isCurrentUserOriginalOwner', () => {
async function loadTeamWithMembers(
members: Array<{
id: string
name: string
email: string
joined_at: string
is_original_owner?: boolean
}>
) {
mockWorkspaceApi.listMembers.mockResolvedValue({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
return store
}
const ownerSelf = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z',
role: 'owner' as const,
is_original_owner: true
}
const promotedSelf = { ...ownerSelf, is_original_owner: false }
it('is true when the self-row is the original owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('matches the self-row by email case-insensitively', async () => {
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('is false when the self-row is a promoted (non-creator) owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([promotedSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the self-row omits is_original_owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
const store = await loadTeamWithMembers([selfWithoutFlag])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('is false when no member row matches the current user', async () => {
mockCurrentUser.userEmail.value = 'someone-else@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when members are not loaded', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the current user email is unknown', async () => {
mockCurrentUser.userEmail.value = null
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('recomputes reactively when the self-row arrives after an empty read', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [ownerSelf],
pagination: { offset: 0, limit: 50, total: 1 }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
await store.fetchMembers()
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
})
describe('invite actions', () => {
it('fetchPendingInvites updates active workspace invites', async () => {
const mockInvites = [

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -22,7 +21,6 @@ export interface WorkspaceMember {
email: string
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
}
export interface PendingInvite {
@@ -51,8 +49,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
role: member.role
}
}
@@ -149,18 +146,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
() => activeWorkspace.value?.members ?? []
)
// True when the current user is the active workspace's original owner,
// resolved from the self-row of the loaded members list. Matches by email
// (the stable current-user join key; member.id is a cloud user id, not the
// Firebase uid). Fails closed when members are not loaded or no self-row
// matches, so lifecycle gating stays hidden until the real signal arrives.
const isCurrentUserOriginalOwner = computed(() => {
const email = useCurrentUser().userEmail.value?.toLowerCase()
if (!email) return false
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
return selfRow?.isOriginalOwner ?? false
})
const pendingInvites = computed<PendingInvite[]>(
() => activeWorkspace.value?.pendingInvites ?? []
)
@@ -522,36 +507,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
return members
}
// Tracks which team workspaces have already loaded their members so the
// lifecycle gate resolves without redundant or duplicate fetches.
const loadedMemberWorkspaceIds = new Set<string>()
let inFlightMembersWorkspaceId: string | null = null
/**
* Load the active team workspace's members once. No-ops for personal or
* already-loaded workspaces and dedupes concurrent calls. A failed request is
* logged and leaves the workspace unloaded so a later call retries.
*/
async function ensureMembersLoaded(): Promise<void> {
const workspaceId = activeWorkspaceId.value
if (!workspaceId) return
if (activeWorkspace.value?.type === 'personal') return
if (loadedMemberWorkspaceIds.has(workspaceId)) return
if (inFlightMembersWorkspaceId === workspaceId) return
inFlightMembersWorkspaceId = workspaceId
try {
await fetchMembers()
loadedMemberWorkspaceIds.add(workspaceId)
} catch (e) {
console.error('Failed to load workspace members', e)
} finally {
if (inFlightMembersWorkspaceId === workspaceId) {
inFlightMembersWorkspaceId = null
}
}
}
/**
* Remove a member from the current workspace.
*/
@@ -697,7 +652,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
ownedWorkspacesCount,
canCreateWorkspace,
members,
isCurrentUserOriginalOwner,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
@@ -721,7 +675,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Member Actions
fetchMembers,
ensureMembersLoaded,
removeMember,
// Invite Actions

View File

@@ -18,7 +18,6 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
const cloudOnboardingRoutes = isCloud
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
@@ -170,7 +169,6 @@ if (isCloud) {
// Pass authenticated users
const authHeader = await authStore.getAuthHeader()
const isLoggedIn = !!authHeader
preserveLoggedOutShareAuthAttribution(to.query, isLoggedIn)
// Allow public routes
if (isPublicRoute(to)) {

View File

@@ -79,56 +79,4 @@ describe('dialogService Reka renderer opt-in', () => {
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
it("showTopUpCreditsDialog() sets renderer 'reka' with a transparent shrink-wrapped chrome", async () => {
await useDialogService().showTopUpCreditsDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.pt).toBeUndefined()
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
expect(args.dialogComponentProps.contentClass).toContain('bg-transparent')
})
it("showLayoutDialog() defaults to renderer 'reka' headless without pt", () => {
const Component = { template: '<div />' }
useDialogService().showLayoutDialog({
key: 'layout-test',
component: Component,
props: {}
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.pt).toBeUndefined()
})
it('showLayoutDialog() lets callers override the defaults', () => {
const Component = { template: '<div />' }
useDialogService().showLayoutDialog({
key: 'layout-override-test',
component: Component,
props: {},
dialogComponentProps: { closable: false, contentClass: 'w-170' }
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.closable).toBe(false)
expect(args.dialogComponentProps.contentClass).toBe('w-170')
})
it("showSmallLayoutDialog() sets renderer 'reka' with zeroed section padding", () => {
const Component = { template: '<div />' }
useDialogService().showSmallLayoutDialog({
key: 'small-layout-test',
component: Component
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.pt).toBeUndefined()
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
expect(args.dialogComponentProps.headerClass).toBe('p-0')
expect(args.dialogComponentProps.bodyClass).toBe('p-0 overflow-y-hidden')
expect(args.dialogComponentProps.footerClass).toBe('p-0')
})
})

View File

@@ -33,20 +33,6 @@ const lazyCloudNotificationContent = () =>
const lazyPublishDialog = () =>
import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue')
/**
* Shrink-wrap the Reka DialogContent around the content's intrinsic width,
* like the auto-sized PrimeVue root it replaces.
*/
const HUG_CONTENT_CLASS =
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)]'
/**
* Reka chrome for headless dialogs whose content draws its own panel
* (background/border/rounding) — neutralize the DialogContent box and
* shrink-wrap it around the content.
*/
const SELF_STYLED_PANEL_CONTENT_CLASS = `${HUG_CONTENT_CLASS} border-none bg-transparent shadow-none`
export type ConfirmationDialogType =
| 'default'
| 'overwrite'
@@ -213,8 +199,6 @@ export const useDialogService = () => {
},
headerComponent: ComfyOrgHeader,
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS,
closable: false,
onClose: () => resolve(false)
}
@@ -238,10 +222,6 @@ export const useDialogService = () => {
onSuccess: () => resolve(true)
},
dialogComponentProps: {
renderer: 'reka',
// SignInContent is a fixed w-96 — size 'sm' (max-w-sm) leaves only
// 352px after the body padding; hug the intrinsic width instead.
contentClass: HUG_CONTENT_CLASS,
closable: true,
onClose: () => resolve(false)
}
@@ -347,9 +327,12 @@ export const useDialogService = () => {
component,
props: options,
dialogComponentProps: {
renderer: 'reka',
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
@@ -368,10 +351,6 @@ export const useDialogService = () => {
props: {
onSuccess: () =>
dialogStore.closeDialog({ key: 'global-update-password' })
},
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS
}
})
}
@@ -401,10 +380,20 @@ export const useDialogService = () => {
dialogComponentProps?: DialogComponentProps
}) {
const layoutDefaultProps: DialogComponentProps = {
renderer: 'reka',
headless: true,
modal: true,
closable: true
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: 'p-0! hidden'
},
content: {
class: 'p-0! m-0!'
}
}
}
return dialogStore.showDialog({
@@ -426,15 +415,18 @@ export const useDialogService = () => {
return dialogStore.showDialog({
...rest,
dialogComponentProps: {
renderer: 'reka',
closable: true,
// Contents bring their own width and separators — shrink-wrap the
// chrome and zero the section padding.
contentClass:
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-border-default',
headerClass: 'p-0',
bodyClass: 'p-0 overflow-y-hidden',
footerClass: 'p-0',
pt: {
root: { class: 'bg-base-background border-border-default' },
header: { class: '!p-0 !m-0' },
content: { class: '!p-0 overflow-y-hidden' },
footer: { class: '!p-0' },
pcCloseButton: {
root: {
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
}
}
},
...callerProps
}
})
@@ -454,10 +446,13 @@ export const useDialogService = () => {
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogProps = {
renderer: 'reka',
const workspaceDialogPt = {
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
async function showDeleteWorkspaceDialog(options?: {
@@ -470,7 +465,7 @@ export const useDialogService = () => {
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -484,7 +479,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -503,7 +498,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -514,7 +509,7 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -525,7 +520,7 @@ export const useDialogService = () => {
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -537,7 +532,7 @@ export const useDialogService = () => {
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -548,7 +543,7 @@ export const useDialogService = () => {
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -560,7 +555,7 @@ export const useDialogService = () => {
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -572,7 +567,7 @@ export const useDialogService = () => {
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -602,7 +597,7 @@ export const useDialogService = () => {
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -617,8 +612,9 @@ export const useDialogService = () => {
props: {},
dialogComponentProps: {
closable: false,
contentClass:
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
pt: {
root: { class: 'w-170 max-h-[85vh]' }
},
onClose: () => resolve()
}
})
@@ -632,13 +628,12 @@ export const useDialogService = () => {
key,
component: ComfyHubPublishDialog,
props: {
onClose: () => dialogStore.closeDialog({ key }),
// Falls through to the BaseModalLayout root — keeps the e2e
// publish-dialog selector working without the PrimeVue pt hook.
'data-testid': 'publish-dialog'
onClose: () => dialogStore.closeDialog({ key })
},
dialogComponentProps: {
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
root: { 'data-testid': 'publish-dialog' }
}
}
})
}

View File

@@ -6,10 +6,7 @@ import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import {
capturePreservedQuery,
clearPreservedQuery
} from '@/platform/navigation/preservedQueryManager'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -690,104 +687,31 @@ describe('useAuthStore', () => {
)
}
)
})
})
describe('share auth attribution', () => {
const mockUserCredential = {
user: mockUser
} as Partial<UserCredential> as UserCredential
it('includes preserved share id on new-user social auth', async () => {
sessionStorage.setItem(
'Comfy.PreservedQuery.share_auth',
JSON.stringify({ share: 'share-1' })
)
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
})
const preserveShareAuth = () => {
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ share: 'share-1' },
['share']
)
}
await store.loginWithGoogle()
const expectShareAuthConsumed = () => {
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
}
beforeEach(() => {
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({
is_new_user: true,
share_id: 'share-1'
})
)
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
})
})
it('includes share_id on email signup auth completion', async () => {
preserveShareAuth()
await store.register('new@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'email',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on email login auth completion', async () => {
preserveShareAuth()
await store.login('test@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'email',
is_new_user: false,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on Google auth completion', async () => {
preserveShareAuth()
await store.loginWithGoogle()
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'google',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on GitHub auth completion', async () => {
preserveShareAuth()
await store.loginWithGithub()
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'github',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
})
describe('accessBillingPortal', () => {

View File

@@ -21,10 +21,10 @@ type DialogPosition =
| 'bottomright'
/**
* Selects the dialog renderer used by `GlobalDialog`. `'reka'` (the default)
* renders the Reka-UI primitive set under `src/components/ui/dialog/`.
* `'primevue'` is the legacy PrimeVue `Dialog` escape hatch, kept only until
* the branch is deleted in the Phase 6 cleanup (FE-578).
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
*/
type DialogRenderer = 'primevue' | 'reka'
@@ -201,7 +201,6 @@ export const useDialogStore = defineStore('dialog', () => {
closable: true,
closeOnEscape: true,
dismissableMask: true,
renderer: 'reka' as DialogRenderer,
...options.dialogComponentProps,
maximized: false,
onMaximize: () => {

View File

@@ -1,6 +1,5 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
@@ -12,30 +11,24 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
// Create mock functions that will be shared
const {
mockNodeExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeLocatorIdToNodeExecutionId,
mockActiveWorkflow,
mockOpenWorkflows,
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun
} = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockActiveWorkflow: shallowRef<{ path?: string } | null>(null),
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}
})
} = vi.hoisted(() => ({
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
const mockAppModeState = vi.hoisted(() => ({
mode: { value: 'graph' },
@@ -54,6 +47,7 @@ beforeEach(() => {
mockAppModeState.mode.value = 'graph'
mockAppModeState.isAppMode.value = false
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -67,15 +61,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: vi.fn(() => ({
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
get activeWorkflow() {
return mockActiveWorkflow.value
},
get openWorkflows() {
return mockOpenWorkflows.value
},
isOpen: (workflow: { path?: string }) =>
mockOpenWorkflows.value.some((w) => w.path === workflow.path)
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
}))
}
})
@@ -149,11 +135,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
beforeEach(() => {
mockActiveWorkflow.value = null
mockOpenWorkflows.value = []
})
function createQueuedWorkflow(path: string = 'workflows/test.json') {
return {
activeState: { id: 'workflow-id' },
@@ -520,254 +501,6 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - workflowStatus', () => {
let store: ReturnType<typeof useExecutionStore>
type Workflow = Parameters<typeof store.storeJob>[0]['workflow']
const makeWorkflow = (path: string): Workflow => {
const workflow: Partial<Workflow> = {
path,
filename: path.split('/').pop()
}
return workflow as Workflow
}
const workflowA = makeWorkflow('/workflows/a.json')
const workflowB = makeWorkflow('/workflows/b.json')
function fireExecutionStart(jobId: string) {
const handler = apiEventHandlers.get('execution_start')
if (!handler) throw new Error('execution_start handler not bound')
handler(
new CustomEvent('execution_start', { detail: { prompt_id: jobId } })
)
}
function fireExecutionSuccess(jobId: string) {
const handler = apiEventHandlers.get('execution_success')
if (!handler) throw new Error('execution_success handler not bound')
handler(
new CustomEvent('execution_success', { detail: { prompt_id: jobId } })
)
}
function fireExecutionError(jobId: string) {
const handler = apiEventHandlers.get('execution_error')
if (!handler) throw new Error('execution_error handler not bound')
handler(
new CustomEvent('execution_error', {
detail: {
prompt_id: jobId,
node_id: '1',
node_type: 'TestNode',
exception_message: 'fail',
exception_type: 'Error',
traceback: []
}
})
)
}
function fireExecutionInterrupted(jobId: string) {
const handler = apiEventHandlers.get('execution_interrupted')
if (!handler) throw new Error('execution_interrupted handler not bound')
handler(
new CustomEvent('execution_interrupted', {
detail: { prompt_id: jobId }
})
)
}
function callStoreJob(jobId: string, workflow: Workflow) {
store.storeJob({
nodes: ['1'],
id: jobId,
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
workflow
})
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
mockOpenWorkflows.value = [workflowA, workflowB]
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
it('sets running on execution_start when storeJob already ran', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes running status when storeJob arrives after WS', () => {
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes terminal completed when WS finishes before storeJob', () => {
// Instant-finish race: WS fires start+success before HTTP response.
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('flushes terminal failed when WS errors before storeJob', () => {
// Invalid-workflow path: execution_error fires before HTTP response.
fireExecutionError('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('drops pending status on interrupt before storeJob', () => {
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('sets completed on execution_success', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionError('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('skips status badge on user-initiated interrupt', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
for (let i = 0; i <= MAX_PROGRESS_JOBS; i++) fireExecutionStart(`job-${i}`)
callStoreJob('job-0', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob(`job-${MAX_PROGRESS_JOBS}`, workflowB)
expect(store.getWorkflowStatus(workflowB)).toBe('running')
})
it('overwrites stale terminal with running on re-queue', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
// Re-queue the same workflow under a fresh jobId.
callStoreJob('job-2', workflowA)
fireExecutionStart('job-2')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('ignores status events for unknown prompt ids', () => {
fireExecutionSuccess('unknown-job')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBeUndefined()
})
it('prunes only closed workflows, leaving open ones intact', async () => {
callStoreJob('job-a', workflowA)
callStoreJob('job-b', workflowB)
fireExecutionSuccess('job-a')
fireExecutionSuccess('job-b')
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBe('completed')
})
it('ignores terminal events for a workflow closed mid-run', async () => {
callStoreJob('job-a', workflowA)
fireExecutionStart('job-a')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Close the tab while the job is still running.
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
// A late success must not resurrect an entry for the closed workflow.
fireExecutionSuccess('job-a')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('drops service-level errors without writing failed', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Service-level error: empty node_id triggers the short-circuit branch.
const handler = apiEventHandlers.get('execution_error')
handler!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('drops pending failed when service-level error fires before storeJob', () => {
apiEventHandlers.get('execution_error')!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('clears workflowStatus on unbindExecutionEvents', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
store.unbindExecutionEvents()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
})
describe('useExecutionStore - clearActiveJobIfStale', () => {
let store: ReturnType<typeof useExecutionStore>

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { useAppMode } from '@/composables/useAppMode'
@@ -93,17 +93,6 @@ function buildExecutionNodeLookup(
*/
export const MAX_PROGRESS_JOBS = 1000
export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed'
export const WORKFLOW_STATUS_I18N_KEYS: Record<
WorkflowExecutionStatus,
string
> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -132,86 +121,6 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<JobId>>(new Set())
const workflowStatus = shallowRef<
Map<ComfyWorkflow, WorkflowExecutionStatus>
>(new Map())
const jobIdToWorkflow = new Map<string, ComfyWorkflow>()
// Buffers statuses arriving before storeJob attaches the workflow.
// FIFO-capped to bound growth if a matching storeJob never fires.
const pendingWorkflowStatusByJobId = new Map<
string,
WorkflowExecutionStatus
>()
function bufferPendingWorkflowStatus(
jobId: string,
status: WorkflowExecutionStatus
) {
pendingWorkflowStatusByJobId.delete(jobId)
pendingWorkflowStatusByJobId.set(jobId, status)
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
const oldest = pendingWorkflowStatusByJobId.keys().next().value
if (oldest === undefined) break
pendingWorkflowStatusByJobId.delete(oldest)
}
}
function mutateStatus(
mutator: (map: Map<ComfyWorkflow, WorkflowExecutionStatus>) => void
) {
const next = new Map(workflowStatus.value)
mutator(next)
workflowStatus.value = next
}
function applyWorkflowStatus(
workflow: ComfyWorkflow,
status: WorkflowExecutionStatus
) {
// A late terminal event can arrive after the tab closed; don't resurrect
// an entry (which also pins the workflow ref) for a closed workflow.
if (!workflowStore.isOpen(workflow)) return
mutateStatus((m) => m.set(workflow, status))
}
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
const workflow = jobIdToWorkflow.get(jobId)
if (!workflow) {
bufferPendingWorkflowStatus(jobId, status)
return
}
applyWorkflowStatus(workflow, status)
}
function clearWorkflowStatus(workflow: ComfyWorkflow) {
if (!workflowStatus.value.has(workflow)) return
mutateStatus((m) => m.delete(workflow))
}
function getWorkflowStatus(
workflow: ComfyWorkflow | undefined | null
): WorkflowExecutionStatus | undefined {
if (!workflow) return undefined
return workflowStatus.value.get(workflow)
}
// Prune statuses for workflows that have been closed.
watch(
() => workflowStore.openWorkflows,
(openWorkflows) => {
if (workflowStatus.value.size === 0) return
const openSet = new Set(openWorkflows)
const filtered = new Map(
[...workflowStatus.value].filter(([w]) => openSet.has(w))
)
if (filtered.size !== workflowStatus.value.size) {
workflowStatus.value = filtered
}
}
)
/**
* Cache for executionIdToNodeLocatorId lookups.
* Avoids redundant graph traversals during a single execution run.
@@ -364,10 +273,6 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
pendingWorkflowStatusByJobId.clear()
jobIdToWorkflow.clear()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -383,7 +288,6 @@ export const useExecutionStore = defineStore('execution', () => {
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
}
setWorkflowStatus(activeJobId.value, 'running')
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -397,10 +301,6 @@ export const useExecutionStore = defineStore('execution', () => {
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const jobId = e.detail.prompt_id
// User-initiated stop is not a failure — drop the badge entirely.
pendingWorkflowStatusByJobId.delete(jobId)
const workflow = jobIdToWorkflow.get(jobId)
if (workflow) clearWorkflowStatus(workflow)
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
resetExecutionState(jobId)
}
@@ -412,7 +312,6 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'completed')
const queuedJob = queuedJobs.value[jobId]
const telemetry = useTelemetry()
if (queuedJob) {
@@ -534,11 +433,7 @@ export const useExecutionStore = defineStore('execution', () => {
if (isCloud) {
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
// Pre-flight validation isn't a runtime failure — no badge.
if (handleCloudValidationError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
if (handleCloudValidationError(e.detail)) return
}
// Account preconditions (sign-in, subscription, credits) open their own
@@ -546,12 +441,10 @@ export const useExecutionStore = defineStore('execution', () => {
if (handleAccountPreconditionError(e.detail)) return
// Service-level errors (e.g. "Job has stagnated") have no associated node.
if (handleServiceLevelError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
// Route them as job errors
if (handleServiceLevelError(e.detail)) return
setWorkflowStatus(e.detail.prompt_id, 'failed')
// OSS path / Cloud fallback (real runtime errors)
executionErrorStore.lastExecutionError = e.detail
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
@@ -676,7 +569,6 @@ export const useExecutionStore = defineStore('execution', () => {
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
@@ -732,7 +624,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
if (workflow) jobIdToWorkflow.set(String(id), workflow)
queuedJob.shareId = workflow?.shareId
const queuedMode = getWorkflowMode(workflow)
queuedJob.viewMode = queuedMode
@@ -744,19 +635,6 @@ export const useExecutionStore = defineStore('execution', () => {
if (workflow?.path) {
ensureSessionWorkflowPath(id, workflow.path)
}
flushPendingWorkflowStatus(String(id), workflow)
}
function flushPendingWorkflowStatus(
jobId: string,
workflow: ComfyWorkflow | undefined
) {
const pending = pendingWorkflowStatusByJobId.get(jobId)
if (pending === undefined || !workflow) return
pendingWorkflowStatusByJobId.delete(jobId)
// Don't let a stale 'running' overwrite a terminal status already set.
if (pending === 'running' && workflowStatus.value.has(workflow)) return
applyWorkflowStatus(workflow, pending)
}
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
@@ -851,8 +729,6 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath,
getWorkflowStatus,
clearWorkflowStatus
ensureSessionWorkflowPath
}
})