mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 00:35:52 +00:00
Compare commits
11 Commits
chore/code
...
bl/fix-sha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998bae8054 | ||
|
|
81008ac667 | ||
|
|
13336cc5a0 | ||
|
|
56b05c0fd5 | ||
|
|
403353ac77 | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 |
@@ -5,7 +5,6 @@ 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'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// 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.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -231,6 +231,22 @@ 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(
|
||||
|
||||
@@ -38,7 +38,6 @@ 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',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs 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 mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// 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 expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ 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 ({
|
||||
|
||||
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
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',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
233
src/components/topbar/WorkflowTab.test.ts
Normal file
233
src/components/topbar/WorkflowTab.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,19 @@
|
||||
{{ 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-if="shouldShowStatusIndicator"
|
||||
v-else-if="shouldShowUnsavedIndicator"
|
||||
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
|
||||
@@ -32,6 +43,7 @@
|
||||
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" />
|
||||
@@ -85,8 +97,14 @@ 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'
|
||||
|
||||
@@ -113,6 +131,7 @@ 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()
|
||||
@@ -125,7 +144,7 @@ const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
const shouldShowStatusIndicator = computed(() => {
|
||||
const shouldShowUnsavedIndicator = computed(() => {
|
||||
if (workspaceStore.shiftDown) {
|
||||
// Branch 1: Shift key is held down, do not show the status indicator.
|
||||
return false
|
||||
@@ -160,6 +179,27 @@ 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)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
|
||||
useWorkflowStatusDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
|
||||
@@ -117,6 +117,7 @@ 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'
|
||||
@@ -145,6 +146,9 @@ 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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,7 +15,14 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,7 +28,26 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
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 {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,10 +8,23 @@ 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'>(() =>
|
||||
|
||||
@@ -9,19 +9,13 @@ export const useQueueClearHistoryDialog = () => {
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
// The content draws its own panel — neutralize the chrome box.
|
||||
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
type ResizeDirection =
|
||||
export type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,6 +17,17 @@ 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. */
|
||||
@@ -264,17 +275,6 @@ 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[]>(() => {
|
||||
|
||||
98
src/composables/useWorkflowStatusDismissal.test.ts
Normal file
98
src/composables/useWorkflowStatusDismissal.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
22
src/composables/useWorkflowStatusDismissal.ts
Normal file
22
src/composables/useWorkflowStatusDismissal.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,15 @@ 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +154,10 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = 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
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -3340,7 +3340,7 @@
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
|
||||
"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.",
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -133,7 +132,6 @@ app
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
|
||||
@@ -144,7 +144,6 @@ 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,
|
||||
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -279,12 +281,15 @@ 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 ''
|
||||
// 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 (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -23,6 +24,10 @@ 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()
|
||||
@@ -47,7 +52,8 @@ export const useAssetBrowserDialog = () => {
|
||||
currentValue: props.currentValue,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
}
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +72,8 @@ export const useAssetBrowserDialog = () => {
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
}
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -774,6 +774,10 @@ export function useMediaAssetActions() {
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ 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
|
||||
@@ -31,12 +40,7 @@ export function useModelUpload(
|
||||
key: 'upload-model-upgrade',
|
||||
headerComponent: UploadModelUpgradeModalHeader,
|
||||
component: UploadModelUpgradeModal,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
} else {
|
||||
dialogStore.showDialog({
|
||||
@@ -49,12 +53,7 @@ export function useModelUpload(
|
||||
await onUploadSuccess?.(result)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -417,4 +419,124 @@ 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +216,64 @@ 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
|
||||
|
||||
@@ -49,13 +49,9 @@ export const useSubscriptionDialog = () => {
|
||||
),
|
||||
props: { onClose: hide },
|
||||
dialogComponentProps: {
|
||||
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' }
|
||||
}
|
||||
renderer: 'reka',
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -89,16 +85,14 @@ export const useSubscriptionDialog = () => {
|
||||
component,
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
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'
|
||||
}
|
||||
}
|
||||
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -122,16 +116,10 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
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)]'
|
||||
}
|
||||
}
|
||||
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'
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -258,15 +258,15 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures events with metadata', async () => {
|
||||
it('captures auth events with metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ method: 'google' })
|
||||
provider.trackAuth({ method: 'google', share_id: 'share-1' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{ method: 'google' }
|
||||
{ method: 'google', share_id: 'share-1' }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -66,11 +66,7 @@ export function useShareDialog() {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,11 +266,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
})
|
||||
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
|
||||
'share_auth',
|
||||
{ share: 'share-id-1' },
|
||||
['share']
|
||||
)
|
||||
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
|
||||
@@ -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,10 +49,6 @@ 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(
|
||||
@@ -110,11 +106,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve({ action: 'cancel' }),
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -133,7 +125,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
return 'not-present'
|
||||
}
|
||||
|
||||
if (!isValidParameter(shareParam)) {
|
||||
if (!isValidShareId(shareParam)) {
|
||||
console.warn(
|
||||
`[useSharedWorkflowUrlLoader] Invalid share parameter format: ${shareParam}`
|
||||
)
|
||||
@@ -152,13 +144,6 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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' } },
|
||||
{ name: 'too long', query: { share: 'a'.repeat(129) } }
|
||||
]
|
||||
|
||||
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: 'a'.repeat(128), expected: true },
|
||||
{ shareId: '../share-id-1', expected: false },
|
||||
{ shareId: '.', expected: false },
|
||||
{ shareId: '..', expected: false },
|
||||
{ shareId: '-share-id', expected: false },
|
||||
{ shareId: 'share id', expected: false },
|
||||
{ shareId: '\u5171\u4eab', expected: false },
|
||||
{ shareId: 'a'.repeat(129), expected: false },
|
||||
{ shareId: '', expected: false }
|
||||
])('validates "$shareId" as $expected', ({ shareId, expected }) => {
|
||||
expect(isValidShareId(shareId)).toBe(expected)
|
||||
})
|
||||
})
|
||||
28
src/platform/workflow/sharing/utils/shareAuthAttribution.ts
Normal file
28
src/platform/workflow/sharing/utils/shareAuthAttribution.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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'
|
||||
const MAX_SHARE_ID_LENGTH = 128
|
||||
const SHARE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/
|
||||
|
||||
export function isValidShareId(shareId: string): boolean {
|
||||
return shareId.length <= MAX_SHARE_ID_LENGTH && SHARE_ID_PATTERN.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]
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,11 @@ 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 {
|
||||
|
||||
@@ -113,7 +113,11 @@
|
||||
button-variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
v-if="
|
||||
showSubscribeAction &&
|
||||
!isPersonalWorkspace &&
|
||||
(!isCancelled || permissions.canManageSubscriptionLifecycle)
|
||||
"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
|
||||
@@ -128,9 +128,10 @@
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@@ -161,7 +162,7 @@
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan"
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -513,15 +514,23 @@ const subscriptionTierName = computed(() => {
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
// 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 tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
|
||||
@@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
@@ -153,6 +154,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useMembersPanel() {
|
||||
name: userDisplayName.value ?? '',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
}))
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
activeWorkspace: null as WorkspaceWithRole | null,
|
||||
isCurrentUserOriginalOwner: false,
|
||||
ensureMembersLoaded: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
return mockStore.activeWorkspace
|
||||
},
|
||||
get isCurrentUserOriginalOwner() {
|
||||
return mockStore.isCurrentUserOriginalOwner
|
||||
},
|
||||
ensureMembersLoaded: mockStore.ensureMembersLoaded
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -46,14 +52,20 @@ async function loadComposable() {
|
||||
return module.useWorkspaceUI()
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
mockStore.activeWorkspace = null
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
mockStore.ensureMembersLoaded.mockReset()
|
||||
}
|
||||
|
||||
describe('useWorkspaceUI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('when no active workspace', () => {
|
||||
@@ -71,7 +83,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = personalWorkspace
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
})
|
||||
|
||||
it('grants billing access but disables team management', async () => {
|
||||
@@ -119,7 +131,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as owner', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
})
|
||||
|
||||
it('grants full management permissions', async () => {
|
||||
@@ -159,7 +171,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as member', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamMemberWorkspace
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
})
|
||||
|
||||
it('restricts management actions while allowing leave', async () => {
|
||||
@@ -195,9 +207,60 @@ 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 () => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
const first = await loadComposable()
|
||||
const second = await loadComposable()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -14,6 +14,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -34,7 +38,8 @@ interface WorkspaceUIConfig {
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
@@ -46,6 +51,8 @@ function getPermissions(
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -60,6 +67,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -74,6 +82,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
}
|
||||
@@ -145,8 +154,26 @@ 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)
|
||||
getPermissions(
|
||||
workspaceType.value,
|
||||
workspaceRole.value,
|
||||
store.isCurrentUserOriginalOwner
|
||||
)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
|
||||
@@ -29,6 +29,15 @@ 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(),
|
||||
@@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
sessionStorage.clear()
|
||||
mockCurrentUser.userEmail.value = null
|
||||
|
||||
// Reset workspaceAuthStore mock state
|
||||
mockWorkspaceAuthStore.currentWorkspace = null
|
||||
@@ -680,6 +690,193 @@ 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 = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -21,6 +22,7 @@ export interface WorkspaceMember {
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,18 @@ 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 ?? []
|
||||
)
|
||||
@@ -507,6 +522,36 @@ 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.
|
||||
*/
|
||||
@@ -652,6 +697,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
isCurrentUserOriginalOwner,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
@@ -675,6 +721,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
ensureMembersLoaded,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
|
||||
@@ -18,6 +18,7 @@ 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'))
|
||||
@@ -169,6 +170,7 @@ if (isCloud) {
|
||||
// Pass authenticated users
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
const isLoggedIn = !!authHeader
|
||||
preserveLoggedOutShareAuthAttribution(to.query, isLoggedIn)
|
||||
|
||||
// Allow public routes
|
||||
if (isPublicRoute(to)) {
|
||||
|
||||
@@ -79,4 +79,56 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,20 @@ 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'
|
||||
@@ -199,6 +213,8 @@ export const useDialogService = () => {
|
||||
},
|
||||
headerComponent: ComfyOrgHeader,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: false,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -222,6 +238,10 @@ 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)
|
||||
}
|
||||
@@ -327,12 +347,9 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -351,6 +368,10 @@ export const useDialogService = () => {
|
||||
props: {
|
||||
onSuccess: () =>
|
||||
dialogStore.closeDialog({ key: 'global-update-password' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -380,20 +401,10 @@ export const useDialogService = () => {
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
const layoutDefaultProps: DialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0! hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0! m-0!'
|
||||
}
|
||||
}
|
||||
closable: true
|
||||
}
|
||||
|
||||
return dialogStore.showDialog({
|
||||
@@ -415,18 +426,15 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
...rest,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
closable: true,
|
||||
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'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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',
|
||||
...callerProps
|
||||
}
|
||||
})
|
||||
@@ -446,13 +454,10 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogPt = {
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
@@ -465,7 +470,7 @@ export const useDialogService = () => {
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,7 +484,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -498,7 +503,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -509,7 +514,7 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -520,7 +525,7 @@ export const useDialogService = () => {
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -532,7 +537,7 @@ export const useDialogService = () => {
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,7 +548,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -567,7 +572,7 @@ export const useDialogService = () => {
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,7 +602,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -612,9 +617,8 @@ export const useDialogService = () => {
|
||||
props: {},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
pt: {
|
||||
root: { class: 'w-170 max-h-[85vh]' }
|
||||
},
|
||||
contentClass:
|
||||
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
|
||||
onClose: () => resolve()
|
||||
}
|
||||
})
|
||||
@@ -628,12 +632,13 @@ export const useDialogService = () => {
|
||||
key,
|
||||
component: ComfyHubPublishDialog,
|
||||
props: {
|
||||
onClose: () => dialogStore.closeDialog({ key })
|
||||
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'
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: { 'data-testid': 'publish-dialog' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import {
|
||||
capturePreservedQuery,
|
||||
clearPreservedQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -687,31 +690,106 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
describe('share auth attribution', () => {
|
||||
const mockUserCredential = {
|
||||
user: mockUser,
|
||||
providerId: null,
|
||||
operationType: 'signIn'
|
||||
} satisfies UserCredential
|
||||
|
||||
await store.loginWithGoogle()
|
||||
const preserveShareAuth = () => {
|
||||
capturePreservedQuery(
|
||||
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
|
||||
{ share: 'share-1' },
|
||||
['share']
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
is_new_user: true,
|
||||
share_id: 'share-1'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
|
||||
).toBeNull()
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -21,10 +21,10 @@ type DialogPosition =
|
||||
| 'bottomright'
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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).
|
||||
*/
|
||||
type DialogRenderer = 'primevue' | 'reka'
|
||||
|
||||
@@ -201,6 +201,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closable: true,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
renderer: 'reka' as DialogRenderer,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
onMaximize: () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -11,24 +12,30 @@ 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
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}))
|
||||
} = 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()
|
||||
}
|
||||
})
|
||||
|
||||
const mockAppModeState = vi.hoisted(() => ({
|
||||
mode: { value: 'graph' },
|
||||
@@ -47,7 +54,6 @@ beforeEach(() => {
|
||||
mockAppModeState.mode.value = 'graph'
|
||||
mockAppModeState.isAppMode.value = false
|
||||
})
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -61,7 +67,15 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
|
||||
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
},
|
||||
get openWorkflows() {
|
||||
return mockOpenWorkflows.value
|
||||
},
|
||||
isOpen: (workflow: { path?: string }) =>
|
||||
mockOpenWorkflows.value.some((w) => w.path === workflow.path)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -135,6 +149,11 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
mockOpenWorkflows.value = []
|
||||
})
|
||||
|
||||
function createQueuedWorkflow(path: string = 'workflows/test.json') {
|
||||
return {
|
||||
activeState: { id: 'workflow-id' },
|
||||
@@ -501,6 +520,254 @@ 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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -93,6 +93,17 @@ 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()
|
||||
@@ -121,6 +132,86 @@ 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.
|
||||
@@ -273,6 +364,10 @@ 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>) {
|
||||
@@ -288,6 +383,7 @@ 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>) {
|
||||
@@ -301,6 +397,10 @@ 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)
|
||||
}
|
||||
@@ -312,6 +412,7 @@ 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) {
|
||||
@@ -433,7 +534,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
if (isCloud) {
|
||||
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
|
||||
if (handleCloudValidationError(e.detail)) return
|
||||
// Pre-flight validation isn't a runtime failure — no badge.
|
||||
if (handleCloudValidationError(e.detail)) {
|
||||
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Account preconditions (sign-in, subscription, credits) open their own
|
||||
@@ -441,10 +546,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (handleAccountPreconditionError(e.detail)) return
|
||||
|
||||
// Service-level errors (e.g. "Job has stagnated") have no associated node.
|
||||
// Route them as job errors
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
if (handleServiceLevelError(e.detail)) {
|
||||
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
|
||||
return
|
||||
}
|
||||
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
setWorkflowStatus(e.detail.prompt_id, 'failed')
|
||||
executionErrorStore.lastExecutionError = e.detail
|
||||
clearInitializationByJobId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
@@ -569,6 +676,7 @@ 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]
|
||||
@@ -624,6 +732,7 @@ 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
|
||||
@@ -635,6 +744,19 @@ 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)
|
||||
@@ -729,6 +851,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
ensureSessionWorkflowPath,
|
||||
getWorkflowStatus,
|
||||
clearWorkflowStatus
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user