mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
9 Commits
bl/subscri
...
cloud/1.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd85253dbe | ||
|
|
8dd3ee072e | ||
|
|
e49d1afa61 | ||
|
|
c7943ca1b6 | ||
|
|
9816951a39 | ||
|
|
e7c10aaf77 | ||
|
|
1ddc0bb125 | ||
|
|
fe8dc17d2d | ||
|
|
352f5a0cd4 |
@@ -64,6 +64,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -104,8 +105,7 @@
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal file
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
|
||||
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
||||
// not the server's object_info default.
|
||||
const widgetValue = await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
||||
window.app!.graph.add(node!)
|
||||
const widget = node!.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
})
|
||||
|
||||
// Production resolves via getAssetFilename (user_metadata.filename →
|
||||
// metadata.filename → asset.name). Test fixtures have no metadata
|
||||
// filename, so asset.name is the resolved value.
|
||||
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
})
|
||||
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal file
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Node context menu viewport overflow (#10824)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler toward the lower-left so the menu has limited space below it.
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()!
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height * 0.75
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
test('last menu item "Remove" is reachable via scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
|
||||
{
|
||||
message:
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp',
|
||||
timeout: 3000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// "Remove" is the last item in the More Options menu.
|
||||
// It must become reachable by scrolling the bounded menu list.
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
const didScroll = await rootList.evaluate((el) => {
|
||||
const previousScrollTop = el.scrollTop
|
||||
el.scrollTo({ top: el.scrollHeight })
|
||||
return el.scrollTop > previousScrollTop
|
||||
})
|
||||
expect(didScroll).toBe(true)
|
||||
await expect(removeItem).toBeVisible()
|
||||
})
|
||||
|
||||
test('last menu item "Remove" is clickable and removes the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
await removeItem.scrollIntoViewIfNeeded()
|
||||
await removeItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
treatConfigHintsAsErrors: true,
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
@@ -33,11 +34,9 @@ const config: KnipConfig = {
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
'src/styles/global.css'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
@@ -54,8 +53,6 @@ const config: KnipConfig = {
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
|
||||
627
pnpm-lock.yaml
generated
627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.55.0
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
@@ -89,14 +89,14 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.0.1
|
||||
knip: ^6.3.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
@@ -32,21 +32,14 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +108,7 @@ const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -260,8 +260,26 @@ function handleColorSelect(subOption: SubMenuOption) {
|
||||
hide()
|
||||
}
|
||||
|
||||
function constrainMenuHeight() {
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const rootList = menuInstance?.container?.querySelector(
|
||||
':scope > ul'
|
||||
) as HTMLElement | null
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
isOpen.value = true
|
||||
requestAnimationFrame(constrainMenuHeight)
|
||||
}
|
||||
|
||||
function onMenuHide() {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -33,7 +33,7 @@
|
||||
:aria-label="$t('g.edit')"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-3.5" />
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
|
||||
@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
|
||||
const extraInfo = (options.extra ||
|
||||
options.parentMenu?.options?.extra) as
|
||||
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
|
||||
| undefined
|
||||
// widgets and inputs
|
||||
|
||||
138
src/composables/useReconnectingNotification.test.ts
Normal file
138
src/composables/useReconnectingNotification.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockToastRemove = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd,
|
||||
remove: mockToastRemove
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
disableToast: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Toast.DisableReconnectingToast')
|
||||
return settingMocks.disableToast
|
||||
return undefined
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useReconnectingNotification', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.disableToast = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show toast immediately on reconnecting', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error toast after delay', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses toast when reconnected before delay expires', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(500)
|
||||
onReconnected()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes toast and shows success when reconnected after delay', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
mockToastAdd.mockClear()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'g.reconnected',
|
||||
life: 2000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when toast is disabled via setting', () => {
|
||||
settingMocks.disableToast = true
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when onReconnected is called without prior onReconnecting', () => {
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles multiple reconnecting events without duplicating toasts', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500) // first toast fires
|
||||
onReconnecting() // second reconnecting event
|
||||
vi.advanceTimersByTime(1500) // second toast fires
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
52
src/composables/useReconnectingNotification.ts
Normal file
52
src/composables/useReconnectingNotification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const RECONNECT_TOAST_DELAY_MS = 1500
|
||||
|
||||
export function useReconnectingNotification() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const reconnectingToastShown = ref(false)
|
||||
|
||||
const { start, stop } = useTimeoutFn(
|
||||
() => {
|
||||
toast.add(reconnectingMessage)
|
||||
reconnectingToastShown.value = true
|
||||
},
|
||||
RECONNECT_TOAST_DELAY_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function onReconnecting() {
|
||||
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
|
||||
start()
|
||||
}
|
||||
|
||||
function onReconnected() {
|
||||
stop()
|
||||
|
||||
if (reconnectingToastShown.value) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
reconnectingToastShown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { onReconnecting, onReconnected }
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
103
src/extensions/core/customWidgets.clone.test.ts
Normal file
103
src/extensions/core/customWidgets.clone.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
|
||||
|
||||
class TestCustomComboNode extends LGraphNode {
|
||||
static override title = 'CustomCombo'
|
||||
|
||||
constructor() {
|
||||
super('CustomCombo')
|
||||
this.serialize_widgets = true
|
||||
this.addOutput('value', '*')
|
||||
this.addWidget('combo', 'value', '', () => {}, {
|
||||
values: [] as string[]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function findWidget(node: LGraphNode, name: string) {
|
||||
return node.widgets?.find((widget) => widget.name === name)
|
||||
}
|
||||
|
||||
function getCustomWidgetsExtension(): ComfyExtension {
|
||||
const extension = useExtensionStore().extensions.find(
|
||||
(candidate) => candidate.name === 'Comfy.CustomWidgets'
|
||||
)
|
||||
|
||||
if (!extension) {
|
||||
throw new Error('Comfy.CustomWidgets extension was not registered')
|
||||
}
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
describe('CustomCombo copy/paste', () => {
|
||||
beforeAll(async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
await import('./customWidgets')
|
||||
|
||||
const extension = getCustomWidgetsExtension()
|
||||
await extension.beforeRegisterNodeDef?.(
|
||||
TestCustomComboNode,
|
||||
{ name: 'CustomCombo' } as ComfyNodeDef,
|
||||
app
|
||||
)
|
||||
|
||||
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
|
||||
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
|
||||
}
|
||||
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
|
||||
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves combo options and selected value through clone and paste', () => {
|
||||
const graph = new LGraph()
|
||||
type AppWithRootGraph = { rootGraphInternal?: LGraph }
|
||||
const appWithRootGraph = app as unknown as AppWithRootGraph
|
||||
const previousRootGraph = appWithRootGraph.rootGraphInternal
|
||||
appWithRootGraph.rootGraphInternal = graph
|
||||
|
||||
try {
|
||||
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
|
||||
graph.add(original)
|
||||
|
||||
findWidget(original, 'option1')!.value = 'alpha'
|
||||
findWidget(original, 'option2')!.value = 'beta'
|
||||
findWidget(original, 'option3')!.value = 'gamma'
|
||||
findWidget(original, 'value')!.value = 'beta'
|
||||
|
||||
const clonedSerialised = original.clone()?.serialize()
|
||||
|
||||
expect(clonedSerialised).toBeDefined()
|
||||
|
||||
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
|
||||
pasted.configure(clonedSerialised!)
|
||||
graph.add(pasted)
|
||||
|
||||
expect(findWidget(pasted, 'value')!.value).toBe('beta')
|
||||
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
|
||||
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
|
||||
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
|
||||
expect(findWidget(pasted, 'value')!.options.values).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma'
|
||||
])
|
||||
} finally {
|
||||
appWithRootGraph.rootGraphInternal = previousRootGraph
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
(w) => w.name.startsWith('option') && w.value
|
||||
).map((w) => `${w.value}`)
|
||||
)
|
||||
if (app.configuringGraph) return
|
||||
if (app.configuringGraph || !this.graph) return
|
||||
if (values.includes(`${comboWidget.value}`)) return
|
||||
comboWidget.value = values[0] ?? ''
|
||||
comboWidget.callback?.(comboWidget.value)
|
||||
@@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
|
||||
this.applyToGraph!()
|
||||
)
|
||||
this.onAdded = useChainCallback(this.onAdded, function () {
|
||||
updateCombo()
|
||||
})
|
||||
|
||||
function addOption(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
@@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
const widgetName = `option${newCount}`
|
||||
const widget = node.addWidget('string', widgetName, '', () => {})
|
||||
if (!widget) return
|
||||
let localValue = `${widget.value ?? ''}`
|
||||
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)?.value
|
||||
return (
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
)
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
|
||||
@@ -143,9 +143,10 @@ app.registerExtension({
|
||||
throw new Error(err)
|
||||
}
|
||||
const data = await resp.json()
|
||||
const serverName = data.name ?? name
|
||||
const subfolder = data.subfolder ?? 'webcam'
|
||||
return `${subfolder}/${serverName} [temp]`
|
||||
const serverName = data.name || name
|
||||
const subfolder = data.subfolder || 'webcam'
|
||||
const type = data.type || 'temp'
|
||||
return `${subfolder}/${serverName} [${type}]`
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('GtmTelemetryProvider', () => {
|
||||
event: 'execution_error',
|
||||
node_type: 'KSampler'
|
||||
})
|
||||
expect((entry?.error as string).length).toBe(100)
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -51,7 +50,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -211,7 +209,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipFilterOption,
|
||||
@@ -16,7 +15,6 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -135,7 +133,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -198,7 +195,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -261,7 +257,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -28,6 +28,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
|
||||
const mockAssetsStoreState = vi.hoisted(() => {
|
||||
const inputAssets: AssetItem[] = []
|
||||
return {
|
||||
@@ -55,7 +56,8 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
return mockAssetsStoreState.inputLoading
|
||||
},
|
||||
updateInputs: mockUpdateInputs,
|
||||
getInputName: mockGetInputName
|
||||
getInputName: mockGetInputName,
|
||||
getAssets: mockGetAssets
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -199,67 +201,117 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
describe('cloud asset browser widget', () => {
|
||||
// "Select model" is the fallback from t('widgets.selectModel')
|
||||
// in createAssetWidget when defaultValue is undefined.
|
||||
const PLACEHOLDER = 'Select model'
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'model1.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
function setupCloudAssetWidget(
|
||||
inputSpecOverrides: Partial<InputSpec> = {}
|
||||
) {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: ''
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
...inputSpecOverrides
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
return { mockNode }
|
||||
}
|
||||
|
||||
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
|
||||
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
|
||||
}
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
expect(
|
||||
vi.mocked(assetService.shouldUseAssetBrowser)
|
||||
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
it('should use first cloud asset as default instead of server combo options', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_only_model.safetensors']
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'fallback.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
default: 'fallback.safetensors'
|
||||
// Note: no options array provided
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'fallback.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
default: 'not_in_cloud.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
})
|
||||
|
||||
it('should prefer inputSpec.default when it exists in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'other_model.safetensors' }),
|
||||
createMockAssetItem({ name: 'fallback.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('should fallback to placeholder when cloud assets not loaded', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_model.safetensors']
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type {
|
||||
@@ -104,6 +105,25 @@ const addMultiSelectWidget = (
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default value for a cloud asset widget.
|
||||
* Priority: inputSpec.default (if present in cloud assets) → first cloud
|
||||
* asset → undefined (shows placeholder).
|
||||
*/
|
||||
function resolveCloudDefault(
|
||||
nodeType: string,
|
||||
specDefault: string | undefined
|
||||
): string | undefined {
|
||||
const assets = useAssetsStore().getAssets(nodeType)
|
||||
if (specDefault != null) {
|
||||
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
|
||||
if (inAssets) return specDefault
|
||||
}
|
||||
// empty filename → undefined (shows placeholder)
|
||||
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
|
||||
return filename || undefined
|
||||
}
|
||||
|
||||
function createAssetBrowserWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec,
|
||||
@@ -195,7 +215,14 @@ const addComboWidget = (
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
return createAssetBrowserWidget(node, inputSpec, defaultValue)
|
||||
// Default from cloud assets, not from server combo options.
|
||||
// Server options list local files that may not exist in the user's
|
||||
// cloud asset library, leading to missing-model errors on undo/reload.
|
||||
const cloudDefault = resolveCloudDefault(
|
||||
node.comfyClass ?? '',
|
||||
inputSpec.default
|
||||
)
|
||||
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
|
||||
}
|
||||
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
|
||||
@@ -17,12 +17,12 @@ interface AutogrowGroup {
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export interface UniformSource {
|
||||
interface UniformSource {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export interface UniformSources {
|
||||
interface UniformSources {
|
||||
floats: UniformSource[]
|
||||
ints: UniformSource[]
|
||||
bools: UniformSource[]
|
||||
|
||||
@@ -234,6 +234,163 @@ describe('API Feature Flags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress_text binary message parsing', () => {
|
||||
/**
|
||||
* Build a legacy progress_text binary message:
|
||||
* [4B event_type=3][4B node_id_len][node_id_bytes][text_bytes]
|
||||
*/
|
||||
function buildLegacyProgressText(
|
||||
nodeId: string,
|
||||
text: string
|
||||
): ArrayBuffer {
|
||||
const encoder = new TextEncoder()
|
||||
const nodeIdBytes = encoder.encode(nodeId)
|
||||
const textBytes = encoder.encode(text)
|
||||
const buf = new ArrayBuffer(4 + 4 + nodeIdBytes.length + textBytes.length)
|
||||
const view = new DataView(buf)
|
||||
view.setUint32(0, 3) // event type
|
||||
view.setUint32(4, nodeIdBytes.length)
|
||||
new Uint8Array(buf, 8, nodeIdBytes.length).set(nodeIdBytes)
|
||||
new Uint8Array(buf, 8 + nodeIdBytes.length, textBytes.length).set(
|
||||
textBytes
|
||||
)
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new-format progress_text binary message:
|
||||
* [4B event_type=3][4B prompt_id_len][prompt_id_bytes][4B node_id_len][node_id_bytes][text_bytes]
|
||||
*/
|
||||
function buildNewProgressText(
|
||||
promptId: string,
|
||||
nodeId: string,
|
||||
text: string
|
||||
): ArrayBuffer {
|
||||
const encoder = new TextEncoder()
|
||||
const promptIdBytes = encoder.encode(promptId)
|
||||
const nodeIdBytes = encoder.encode(nodeId)
|
||||
const textBytes = encoder.encode(text)
|
||||
const buf = new ArrayBuffer(
|
||||
4 + 4 + promptIdBytes.length + 4 + nodeIdBytes.length + textBytes.length
|
||||
)
|
||||
const view = new DataView(buf)
|
||||
let offset = 0
|
||||
view.setUint32(offset, 3) // event type
|
||||
offset += 4
|
||||
view.setUint32(offset, promptIdBytes.length)
|
||||
offset += 4
|
||||
new Uint8Array(buf, offset, promptIdBytes.length).set(promptIdBytes)
|
||||
offset += promptIdBytes.length
|
||||
view.setUint32(offset, nodeIdBytes.length)
|
||||
offset += 4
|
||||
new Uint8Array(buf, offset, nodeIdBytes.length).set(nodeIdBytes)
|
||||
offset += nodeIdBytes.length
|
||||
new Uint8Array(buf, offset, textBytes.length).set(textBytes)
|
||||
return buf
|
||||
}
|
||||
|
||||
let dispatchedEvents: { nodeId: string; text: string; prompt_id?: string }[]
|
||||
let listener: EventListener
|
||||
|
||||
beforeEach(async () => {
|
||||
dispatchedEvents = []
|
||||
listener = ((e: CustomEvent) => {
|
||||
dispatchedEvents.push(e.detail)
|
||||
}) as EventListener
|
||||
api.addEventListener('progress_text', listener)
|
||||
|
||||
// Connect the WebSocket so the message handler is active
|
||||
const initPromise = api.init()
|
||||
wsEventHandlers['open'](new Event('open'))
|
||||
wsEventHandlers['message']({
|
||||
data: JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: { exec_info: { queue_remaining: 0 } },
|
||||
sid: 'test-sid'
|
||||
}
|
||||
})
|
||||
})
|
||||
await initPromise
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
api.removeEventListener('progress_text', listener)
|
||||
})
|
||||
|
||||
it('should parse legacy format when server does not support progress_text_metadata', () => {
|
||||
// Restore real getClientFeatureFlags (advertises support)
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_progress_text_metadata: true
|
||||
})
|
||||
// Server does NOT echo support
|
||||
api.serverFeatureFlags.value = {}
|
||||
|
||||
const msg = buildLegacyProgressText('42', 'Generating image...')
|
||||
wsEventHandlers['message']({ data: msg })
|
||||
|
||||
expect(dispatchedEvents).toHaveLength(1)
|
||||
expect(dispatchedEvents[0]).toEqual({
|
||||
nodeId: '42',
|
||||
text: 'Generating image...'
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse new format when server supports progress_text_metadata', () => {
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_progress_text_metadata: true
|
||||
})
|
||||
api.serverFeatureFlags.value = {
|
||||
supports_progress_text_metadata: true
|
||||
}
|
||||
|
||||
const msg = buildNewProgressText('prompt-abc', '42', 'Step 5/20')
|
||||
wsEventHandlers['message']({ data: msg })
|
||||
|
||||
expect(dispatchedEvents).toHaveLength(1)
|
||||
expect(dispatchedEvents[0]).toEqual({
|
||||
nodeId: '42',
|
||||
text: 'Step 5/20',
|
||||
prompt_id: 'prompt-abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not corrupt legacy messages when client advertises support but server does not', () => {
|
||||
// This is the exact bug scenario: client says it supports the flag,
|
||||
// server doesn't, but the decoder checks the client flag and tries
|
||||
// to parse a prompt_id that isn't there.
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_progress_text_metadata: true
|
||||
})
|
||||
api.serverFeatureFlags.value = {}
|
||||
|
||||
// Send multiple legacy messages to ensure none are corrupted
|
||||
const messages = [
|
||||
buildLegacyProgressText('7', 'Loading model...'),
|
||||
buildLegacyProgressText('12', 'Sampling 3/20'),
|
||||
buildLegacyProgressText('99', 'VAE decode')
|
||||
]
|
||||
|
||||
for (const msg of messages) {
|
||||
wsEventHandlers['message']({ data: msg })
|
||||
}
|
||||
|
||||
expect(dispatchedEvents).toHaveLength(3)
|
||||
expect(dispatchedEvents[0]).toMatchObject({
|
||||
nodeId: '7',
|
||||
text: 'Loading model...'
|
||||
})
|
||||
expect(dispatchedEvents[1]).toMatchObject({
|
||||
nodeId: '12',
|
||||
text: 'Sampling 3/20'
|
||||
})
|
||||
expect(dispatchedEvents[2]).toMatchObject({
|
||||
nodeId: '99',
|
||||
text: 'VAE decode'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -638,7 +638,7 @@ export class ComfyApi extends EventTarget {
|
||||
let promptId: string | undefined
|
||||
|
||||
if (
|
||||
this.getClientFeatureFlags()?.supports_progress_text_metadata
|
||||
this.serverFeatureFlags.value?.supports_progress_text_metadata
|
||||
) {
|
||||
const promptIdLength = rawView.getUint32(offset)
|
||||
offset += 4
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
|
||||
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const onReconnecting = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add(reconnectingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const onReconnected = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
|
||||
Reference in New Issue
Block a user