mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 21:21:06 +00:00
Compare commits
8 Commits
austin/app
...
core/1.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce0c679ef | ||
|
|
50ddd904e7 | ||
|
|
e6a59dcdc2 | ||
|
|
924da682ab | ||
|
|
0722a39ba3 | ||
|
|
ae0cf28fc3 | ||
|
|
addd369d85 | ||
|
|
8e08877530 |
@@ -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
|
||||
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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