mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Add integration contract tests (unit) and expanded Playwright coverage for subgraph promotion, hydration, navigation, and lifecycle edge behaviors. ## Changes - **What**: 22 unit/integration tests across 9 files covering promotion store sync, widget view lifecycle, input link resolution, pseudo-widget cache, navigation viewport restore, and subgraph operations. 13 Playwright E2E tests covering proxyWidgets hydration stability, promoted source removal cleanup, pseudo-preview unpack/remove, multi-link representative round-trip, nested promotion retarget, and navigation state on workflow switch. - **Helpers**: Added `isPseudoPreviewEntry`, `getPseudoPreviewWidgets`, `getNonPreviewPromotedWidgets` to promotedWidgets helper. Added `SubgraphHelper.getNodeCount()`. ## Review Focus - Test-only PR — no production code changes - Validates existing subgraph behaviors are covered by regression tests before further feature work - Phase 4 (unit/integration contracts) and Phase 5 (Playwright expansion) of the subgraph test coverage plan ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10123-test-subgraph-integration-contracts-and-expanded-Playwright-coverage-3256d73d365081258023e3a763859e00) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
277 lines
6.9 KiB
TypeScript
277 lines
6.9 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { mount } from '@vue/test-utils'
|
|
import { setActivePinia } from 'pinia'
|
|
import type { Slots } from 'vue'
|
|
import { h } from 'vue'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
|
|
import WidgetActions from './WidgetActions.vue'
|
|
|
|
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
|
mockGetInputSpecForWidget: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/stores/nodeDefStore', () => ({
|
|
useNodeDefStore: () => ({
|
|
getInputSpecForWidget: mockGetInputSpecForWidget
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
|
useCanvasStore: () => ({
|
|
canvas: { setDirty: vi.fn() }
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
|
useFavoritedWidgetsStore: () => ({
|
|
isFavorited: vi.fn().mockReturnValue(false),
|
|
toggleFavorite: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/services/dialogService', () => ({
|
|
useDialogService: () => ({
|
|
prompt: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/components/button/MoreButton.vue', () => ({
|
|
default: (_: unknown, { slots }: { slots: Slots }) =>
|
|
h('div', slots.default?.({ close: () => {} }))
|
|
}))
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
g: {
|
|
rename: 'Rename',
|
|
enterNewName: 'Enter new name'
|
|
},
|
|
rightSidePanel: {
|
|
hideInput: 'Hide input',
|
|
showInput: 'Show input',
|
|
addFavorite: 'Favorite',
|
|
removeFavorite: 'Unfavorite',
|
|
resetToDefault: 'Reset to default'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
describe('WidgetActions', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.resetAllMocks()
|
|
mockGetInputSpecForWidget.mockReturnValue({
|
|
type: 'INT',
|
|
default: 42
|
|
})
|
|
})
|
|
|
|
function createMockWidget(
|
|
value: number = 100,
|
|
callback?: () => void
|
|
): IBaseWidget {
|
|
return {
|
|
name: 'test_widget',
|
|
type: 'number',
|
|
value,
|
|
label: 'Test Widget',
|
|
options: {},
|
|
y: 0,
|
|
callback
|
|
} as IBaseWidget
|
|
}
|
|
|
|
function createMockNode(): LGraphNode {
|
|
return {
|
|
id: 1,
|
|
type: 'TestNode',
|
|
rootGraph: { id: 'graph-test' },
|
|
computeSize: vi.fn(),
|
|
size: [200, 100]
|
|
} as unknown as LGraphNode
|
|
}
|
|
|
|
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
|
return mount(WidgetActions, {
|
|
props: {
|
|
widget,
|
|
node,
|
|
label: 'Test Widget'
|
|
},
|
|
global: {
|
|
plugins: [i18n]
|
|
}
|
|
})
|
|
}
|
|
|
|
it('shows reset button when widget has default value', () => {
|
|
const widget = createMockWidget()
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
expect(resetButton).toBeDefined()
|
|
})
|
|
|
|
it('emits resetToDefault with default value when reset button clicked', async () => {
|
|
const widget = createMockWidget(100)
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
|
|
await resetButton?.trigger('click')
|
|
|
|
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
|
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
|
|
})
|
|
|
|
it('disables reset button when value equals default', () => {
|
|
const widget = createMockWidget(42)
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
|
|
expect(resetButton?.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('does not show reset button when no default value exists', () => {
|
|
mockGetInputSpecForWidget.mockReturnValue({
|
|
type: 'CUSTOM'
|
|
})
|
|
|
|
const widget = createMockWidget(100)
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
|
|
expect(resetButton).toBeUndefined()
|
|
})
|
|
|
|
it('uses fallback default for INT type without explicit default', async () => {
|
|
mockGetInputSpecForWidget.mockReturnValue({
|
|
type: 'INT'
|
|
})
|
|
|
|
const widget = createMockWidget(100)
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
|
|
await resetButton?.trigger('click')
|
|
|
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
|
|
})
|
|
|
|
it('uses first option as default for combo without explicit default', async () => {
|
|
mockGetInputSpecForWidget.mockReturnValue({
|
|
type: 'COMBO',
|
|
options: ['option1', 'option2', 'option3']
|
|
})
|
|
|
|
const widget = createMockWidget(100)
|
|
const node = createMockNode()
|
|
|
|
const wrapper = mountWidgetActions(widget, node)
|
|
|
|
const resetButton = wrapper
|
|
.findAll('button')
|
|
.find((b) => b.text().includes('Reset'))
|
|
|
|
await resetButton?.trigger('click')
|
|
|
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
|
})
|
|
|
|
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
|
mockGetInputSpecForWidget.mockReturnValue({
|
|
type: 'CUSTOM'
|
|
})
|
|
const parentSubgraphNode = {
|
|
id: 4,
|
|
rootGraph: { id: 'graph-test' },
|
|
computeSize: vi.fn(),
|
|
size: [300, 150]
|
|
} as unknown as SubgraphNode
|
|
const node = {
|
|
id: 4,
|
|
type: 'SubgraphNode',
|
|
rootGraph: { id: 'graph-test' }
|
|
} as unknown as LGraphNode
|
|
const widget = {
|
|
name: 'text',
|
|
type: 'text',
|
|
value: 'value',
|
|
label: 'Text',
|
|
options: {},
|
|
y: 0,
|
|
sourceNodeId: '3',
|
|
sourceWidgetName: 'text',
|
|
disambiguatingSourceNodeId: '1'
|
|
} as IBaseWidget
|
|
|
|
const promotionStore = usePromotionStore()
|
|
promotionStore.promote('graph-test', 4, {
|
|
sourceNodeId: '3',
|
|
sourceWidgetName: 'text',
|
|
disambiguatingSourceNodeId: '1'
|
|
})
|
|
|
|
const wrapper = mount(WidgetActions, {
|
|
props: {
|
|
widget,
|
|
node,
|
|
label: 'Text',
|
|
parents: [parentSubgraphNode],
|
|
isShownOnParents: true
|
|
},
|
|
global: {
|
|
plugins: [i18n]
|
|
}
|
|
})
|
|
|
|
const hideButton = wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text().includes('Hide input'))
|
|
expect(hideButton).toBeDefined()
|
|
await hideButton?.trigger('click')
|
|
|
|
expect(
|
|
promotionStore.isPromoted('graph-test', 4, {
|
|
sourceNodeId: '3',
|
|
sourceWidgetName: 'text',
|
|
disambiguatingSourceNodeId: '1'
|
|
})
|
|
).toBe(false)
|
|
})
|
|
})
|