mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 20:21:04 +00:00
Compare commits
9 Commits
test/deskt
...
test-cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119c9764d2 | ||
|
|
4885ef856c | ||
|
|
873a75d607 | ||
|
|
ecb6fbe8fb | ||
|
|
52ccd9ed1a | ||
|
|
92ad6fc798 | ||
|
|
06686a1f50 | ||
|
|
693b8383d6 | ||
|
|
93fddf2343 |
@@ -5,7 +5,6 @@
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import StartupDisplay from '@/components/common/StartupDisplay.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
|
||||
})
|
||||
|
||||
const ProgressBarStub = {
|
||||
props: ['mode', 'value', 'showValue'],
|
||||
template:
|
||||
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
props: {
|
||||
progressPercentage?: number
|
||||
title?: string
|
||||
statusText?: string
|
||||
hideProgress?: boolean
|
||||
fullScreen?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return render(StartupDisplay, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: { ProgressBar: ProgressBarStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StartupDisplay', () => {
|
||||
describe('progressMode', () => {
|
||||
it('renders indeterminate mode when progressPercentage is undefined', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'indeterminate'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders determinate mode when progressPercentage is provided', () => {
|
||||
renderDisplay({ progressPercentage: 50 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'determinate'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes progressPercentage as value to the progress bar', () => {
|
||||
renderDisplay({ progressPercentage: 75 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideProgress', () => {
|
||||
it('hides the progress bar when hideProgress is true', () => {
|
||||
renderDisplay({ hideProgress: true })
|
||||
expect(screen.queryByTestId('progress-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the progress bar by default', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title', () => {
|
||||
it('renders the title text when provided', () => {
|
||||
renderDisplay({ title: 'Loading...' })
|
||||
expect(screen.getByText('Loading...')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render h1 when title is not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusText', () => {
|
||||
it('renders statusText with data-testid attribute', () => {
|
||||
renderDisplay({ statusText: 'Starting server' })
|
||||
expect(screen.getByTestId('startup-status-text').textContent).toContain(
|
||||
'Starting server'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render statusText element when not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByTestId('startup-status-text')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
|
||||
checkUrlReachable: vi.fn()
|
||||
}))
|
||||
|
||||
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<input
|
||||
data-testid="url-input"
|
||||
:value="modelValue"
|
||||
:data-invalid="invalid"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@blur="$emit('blur')"
|
||||
/>`
|
||||
}
|
||||
|
||||
const InputIconStub = {
|
||||
template: '<span data-testid="input-icon" />'
|
||||
}
|
||||
|
||||
const IconFieldStub = {
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
function renderUrlInput(
|
||||
modelValue = '',
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
) {
|
||||
return render(UrlInput, {
|
||||
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('initial validation on mount', () => {
|
||||
it('stays IDLE when modelValue is empty on mount', async () => {
|
||||
renderUrlInput('')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
renderUrlInput('https://example.com')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets INVALID state when URL is not reachable on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://unreachable.example')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('input handling', () => {
|
||||
it('resets validation state to IDLE on user input', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://bad.example')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByTestId('url-input'), 'x')
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
|
||||
it('strips whitespace from typed input', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByTestId('url-input')
|
||||
await user.type(input, 'htt ps')
|
||||
expect((input as HTMLInputElement).value).not.toContain(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blur handling', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes URL on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
const emittedUrl = onUpdate.mock.calls[0]?.[0]
|
||||
expect(typeof emittedUrl).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom validateUrlFn', () => {
|
||||
it('uses custom validateUrlFn when provided', async () => {
|
||||
const customValidator = vi.fn().mockResolvedValue(true)
|
||||
renderUrlInput('https://custom.example', customValidator)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
|
||||
})
|
||||
|
||||
expect(checkUrlReachable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-change emission', () => {
|
||||
it('emits state-change when validation state changes', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onState-change': onStateChange
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
getPlatform: vi.fn().mockReturnValue('win32')
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
te: () => false,
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import GpuPicker from '@/components/install/GpuPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const HardwareOptionStub = {
|
||||
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
|
||||
}
|
||||
|
||||
function renderPicker(device: TorchDeviceType | null = null) {
|
||||
return render(GpuPicker, {
|
||||
props: { device },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
HardwareOption: HardwareOptionStub,
|
||||
Tag: {
|
||||
props: ['value'],
|
||||
template: '<span data-testid="recommended-tag">{{ value }}</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('GpuPicker', () => {
|
||||
describe('recommended badge', () => {
|
||||
it('shows recommended badge for nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows recommended badge for amd', () => {
|
||||
renderPicker('amd')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge when no device is selected', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selection state', () => {
|
||||
it('marks nvidia as selected when device is nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks cpu as selected when device is cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks unsupported as selected when device is unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('no option is selected when device is null', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpu options on non-darwin platform', () => {
|
||||
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('NVIDIA')).toBeDefined()
|
||||
expect(screen.getByTestId('AMD')).toBeDefined()
|
||||
expect(screen.getByTestId('CPU')).toBeDefined()
|
||||
expect(screen.getByTestId('Manual Install')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const mockValidateComfyUISource = vi.fn()
|
||||
const mockShowDirectoryPicker = vi.fn()
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
validateComfyUISource: mockValidateComfyUISource,
|
||||
showDirectoryPicker: mockShowDirectoryPicker
|
||||
}))
|
||||
}))
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
install: {
|
||||
migrationSourcePathDescription: 'Source path description',
|
||||
migrationOptional: 'Migration is optional',
|
||||
selectItemsToMigrate: 'Select items to migrate',
|
||||
pathValidationFailed: 'Validation failed',
|
||||
failedToSelectDirectory: 'Failed to select directory',
|
||||
locationPicker: {
|
||||
migrationPathPlaceholder: 'Enter path'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<input
|
||||
data-testid="source-input"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>`
|
||||
}
|
||||
|
||||
const CheckboxStub = {
|
||||
props: ['modelValue', 'inputId', 'binary'],
|
||||
emits: ['update:modelValue', 'click'],
|
||||
template: `<input
|
||||
type="checkbox"
|
||||
:data-testid="'checkbox-' + inputId"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
@click.stop="$emit('click')"
|
||||
/>`
|
||||
}
|
||||
|
||||
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
|
||||
return render(MigrationPicker, {
|
||||
props: { sourcePath, migrationItemIds },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: {
|
||||
props: ['severity'],
|
||||
template: '<div data-testid="error-msg"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MigrationPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('isValidSource', () => {
|
||||
it('hides migration options when source path is empty', () => {
|
||||
renderPicker('')
|
||||
expect(screen.queryByText('Select items to migrate')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows migration options when source path is valid', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
const { rerender } = renderPicker('')
|
||||
|
||||
await rerender({ sourcePath: '/valid/path' })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select items to migrate')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows optional message when no valid source', () => {
|
||||
renderPicker('')
|
||||
expect(screen.getByText('Migration is optional')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSource', () => {
|
||||
it('clears error when source path becomes empty', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
|
||||
await user.clear(screen.getByTestId('source-input'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when validation fails', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Path not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no error when validation passes', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/valid/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrationItemIds watchEffect', () => {
|
||||
it('emits all item IDs by default (all items start selected)', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
migrationItemIds: [],
|
||||
'onUpdate:migrationItemIds': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const emittedIds = onUpdate.mock.calls[0][0]
|
||||
expect(Array.isArray(emittedIds)).toBe(true)
|
||||
expect(emittedIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browse path', () => {
|
||||
it('opens directory picker on browse click', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue(null)
|
||||
renderPicker()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('updates source path when directory is selected', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
'onUpdate:sourcePath': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
|
||||
const TagStub = defineComponent({
|
||||
name: 'Tag',
|
||||
props: {
|
||||
icon: String,
|
||||
severity: String,
|
||||
value: String
|
||||
},
|
||||
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
|
||||
})
|
||||
|
||||
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
|
||||
return render(StatusTag, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: { Tag: TagStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StatusTag', () => {
|
||||
describe('refreshing state', () => {
|
||||
it('shows info severity when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
})
|
||||
|
||||
it('shows refreshing translation key when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows question icon when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('shows danger severity when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
|
||||
})
|
||||
|
||||
it('shows error translation key when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OK state', () => {
|
||||
it('shows success severity when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
|
||||
})
|
||||
|
||||
it('shows OK translation key when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('precedence', () => {
|
||||
it('shows refreshing state when both refreshing and error are true', () => {
|
||||
renderStatusTag({ error: true, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskCard from '@/components/maintenance/TaskCard.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
shortDescription: 'Short description',
|
||||
errorDescription: 'Error occurred',
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
function renderCard(state: 'OK' | 'error' | 'warning' | 'skipped') {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskCard, {
|
||||
props: { task: baseTask },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
Card: {
|
||||
template:
|
||||
'<div data-testid="card"><slot name="content" /></slot></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskCard', () => {
|
||||
describe('description computed', () => {
|
||||
it('shows errorDescription when task state is error', () => {
|
||||
renderCard('error')
|
||||
expect(screen.getByText('Error occurred')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is OK', () => {
|
||||
renderCard('OK')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is warning', () => {
|
||||
renderCard('warning')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
|
||||
const taskWithoutErrorDesc: MaintenanceTask = {
|
||||
...baseTask,
|
||||
errorDescription: undefined
|
||||
}
|
||||
mockGetRunner.mockReturnValue({
|
||||
state: 'error',
|
||||
executing: false,
|
||||
refreshing: false
|
||||
})
|
||||
|
||||
render(TaskCard, {
|
||||
props: { task: taskWithoutErrorDesc },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
Card: {
|
||||
template:
|
||||
'<div data-testid="card"><slot name="content" /></slot></div>'
|
||||
},
|
||||
Button: { template: '<button />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
button: { text: 'Fix', icon: 'pi pi-check' },
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
const ButtonStub = {
|
||||
props: ['severity', 'label', 'icon', 'loading'],
|
||||
template:
|
||||
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
|
||||
}
|
||||
|
||||
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskListItem, {
|
||||
props: { task: baseTask },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Popover: { template: '<div />' },
|
||||
TaskListStatusIcon: { template: '<span />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListItem', () => {
|
||||
describe('severity computed', () => {
|
||||
it('uses primary severity for error state', () => {
|
||||
renderItem('error')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses primary severity for warning state', () => {
|
||||
renderItem('warning')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for OK state', () => {
|
||||
renderItem('OK')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for skipped state', () => {
|
||||
renderItem('skipped')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
|
||||
|
||||
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
|
||||
|
||||
function renderIcon(state: TaskState, loading?: boolean) {
|
||||
return render(TaskListStatusIcon, {
|
||||
props: { state, loading },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
ProgressSpinner: {
|
||||
template: '<div data-testid="spinner" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListStatusIcon', () => {
|
||||
describe('loading / no state', () => {
|
||||
it('renders spinner when state is undefined', () => {
|
||||
renderIcon(undefined)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders spinner when loading is true', () => {
|
||||
renderIcon('OK', true)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides spinner when state is defined and not loading', () => {
|
||||
renderIcon('OK', false)
|
||||
expect(screen.queryByTestId('spinner')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
|
||||
() => {
|
||||
const mockTerminal = {
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
hasSelection: vi.fn<[], boolean>(),
|
||||
resize: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24
|
||||
}
|
||||
const MockTerminal = vi.fn(function () {
|
||||
return mockTerminal
|
||||
})
|
||||
|
||||
const mockFitAddon = {
|
||||
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
|
||||
}
|
||||
const MockFitAddon = vi.fn(function () {
|
||||
return mockFitAddon
|
||||
})
|
||||
|
||||
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
|
||||
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
|
||||
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
|
||||
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
|
||||
function withSetup<T>(composable: () => T): T {
|
||||
let result!: T
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = composable()
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function getKeyHandler(): (event: KeyboardEvent) => boolean {
|
||||
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
|
||||
}
|
||||
|
||||
describe('useTerminal key event handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
|
||||
const element = ref<HTMLElement | undefined>(undefined)
|
||||
withSetup(() => useTerminal(element))
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Meta+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pass copy to browser when no text is selected', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Ctrl+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Meta+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not intercept non-keydown events', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keyup',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('passes through unrelated key combinations', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
key: 'Enter'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
|
||||
const mockSerialize = vi.fn<[], string>()
|
||||
const MockSerializeAddon = vi.fn(function () {
|
||||
return { serialize: mockSerialize }
|
||||
})
|
||||
return { mockSerialize, MockSerializeAddon }
|
||||
})
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn(function () {
|
||||
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-serialize', () => ({
|
||||
SerializeAddon: MockSerializeAddon
|
||||
}))
|
||||
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
|
||||
|
||||
function withSetup<T>(composable: () => T): T {
|
||||
let result!: T
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = composable()
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useTerminalBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSerialize.mockReturnValue('')
|
||||
})
|
||||
|
||||
describe('copyTo', () => {
|
||||
it('writes serialized buffer content to the destination terminal', () => {
|
||||
mockSerialize.mockReturnValue('hello world')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as unknown as Terminal)
|
||||
expect(mockWrite).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('writes empty string when buffer is empty', () => {
|
||||
mockSerialize.mockReturnValue('')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as unknown as Terminal)
|
||||
expect(mockWrite).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
|
||||
|
||||
describe('getDialog', () => {
|
||||
it('returns the matching dialog for a valid ID', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
expect(result.id).toBe('reinstallVenv')
|
||||
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
|
||||
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
|
||||
})
|
||||
|
||||
it('returns invalidDialog for an unknown string ID', () => {
|
||||
const result = getDialog('unknownDialog')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog when given an array of strings', () => {
|
||||
const result = getDialog(['reinstallVenv', 'other'])
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog for empty string', () => {
|
||||
const result = getDialog('')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('includes id, title, message, and buttons in the result', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
expect(result).toHaveProperty('id')
|
||||
expect(result).toHaveProperty('title')
|
||||
expect(result).toHaveProperty('message')
|
||||
expect(result).toHaveProperty('buttons')
|
||||
expect(Array.isArray(result.buttons)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns a deep clone — mutations do not affect the original', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
|
||||
result.buttons[0].label = 'Mutated'
|
||||
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
|
||||
originalFirstLabel
|
||||
)
|
||||
})
|
||||
|
||||
it('every button has a returnValue', () => {
|
||||
for (const id of Object.keys(DESKTOP_DIALOGS)) {
|
||||
const result = getDialog(id)
|
||||
for (const button of result.buttons) {
|
||||
expect(button.returnValue).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidDialog has a close/cancel button', () => {
|
||||
const result = getDialog('invalidDialog')
|
||||
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
setBasePath: vi.fn(),
|
||||
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
uv: {
|
||||
installRequirements: vi.fn<[], Promise<void>>(),
|
||||
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
|
||||
|
||||
function findTask(id: string) {
|
||||
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
|
||||
if (!task) throw new Error(`Task not found: ${id}`)
|
||||
return task
|
||||
}
|
||||
|
||||
describe('desktopMaintenanceTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
mockElectron.reinstall.mockResolvedValue(undefined)
|
||||
mockElectron.uv.clearCache.mockResolvedValue(undefined)
|
||||
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('pythonPackages', () => {
|
||||
it('returns true when installation succeeds', async () => {
|
||||
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when installation throws', async () => {
|
||||
mockElectron.uv.installRequirements.mockRejectedValue(
|
||||
new Error('install failed')
|
||||
)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL-opening tasks', () => {
|
||||
it('git execute opens a browser tab', () => {
|
||||
findTask('git').execute()
|
||||
expect(window.open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uv execute opens a browser tab', () => {
|
||||
findTask('uv').execute()
|
||||
expect(window.open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('vcRedist execute opens a browser tab', () => {
|
||||
findTask('vcRedist').execute()
|
||||
expect(window.open).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,313 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron, testTasks } = vi.hoisted(() => {
|
||||
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
const basicTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
|
||||
return {
|
||||
mockElectron: {
|
||||
Validation: {
|
||||
validateInstallation: vi.fn()
|
||||
}
|
||||
},
|
||||
testTasks: [
|
||||
{
|
||||
id: 'basicTask',
|
||||
name: 'Basic Task',
|
||||
execute: basicTaskExecute
|
||||
},
|
||||
{
|
||||
id: 'terminalTask',
|
||||
name: 'Terminal Task',
|
||||
execute: terminalTaskExecute,
|
||||
usesTerminal: true,
|
||||
isInstallationFix: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: testTasks
|
||||
}))
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
|
||||
type PartialInstallValidation = Partial<InstallValidation> &
|
||||
Record<string, unknown>
|
||||
|
||||
function makeUpdate(
|
||||
overrides: PartialInstallValidation = {}
|
||||
): InstallValidation {
|
||||
return {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
...overrides
|
||||
} as InstallValidation
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
return useMaintenanceTaskStore()
|
||||
}
|
||||
|
||||
describe('useMaintenanceTaskStore', () => {
|
||||
let store: ReturnType<typeof useMaintenanceTaskStore>
|
||||
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
store = createStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('creates runners for all tasks', () => {
|
||||
expect(store.tasks.length).toBe(testTasks.length)
|
||||
})
|
||||
|
||||
it('starts with isRefreshing false', () => {
|
||||
expect(store.isRefreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with no errors', () => {
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with unsafeBasePath false', () => {
|
||||
expect(store.unsafeBasePath).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with no running terminal commands', () => {
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with no running installation fixes', () => {
|
||||
expect(store.isRunningInstallationFix).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processUpdate', () => {
|
||||
it('sets isRefreshing to true during in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.isRefreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('sets isRefreshing to false when update is complete', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
|
||||
expect(store.isRefreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('updates runner state for tasks present in the final update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('error')
|
||||
})
|
||||
|
||||
it('sets task state to warning from update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('warning')
|
||||
})
|
||||
|
||||
it('marks runners as refreshing when task id is absent from in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('marks task as skipped when absent from final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).state).toBe('skipped')
|
||||
})
|
||||
|
||||
it('clears refreshing flag after final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('stores lastUpdate and exposes unsafeBasePath', () => {
|
||||
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
|
||||
expect(store.unsafeBasePath).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes unsafeBasePathReason from the update', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
|
||||
)
|
||||
expect(store.unsafeBasePathReason).toBe('oneDrive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anyErrors', () => {
|
||||
it('returns true when any task has error state', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.anyErrors).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are warning', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
|
||||
)
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runner state transitions', () => {
|
||||
it('marks runner as resolved when transitioning from error to OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark resolved for warning to OK transition', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears resolved flag when task returns to error', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearResolved', () => {
|
||||
it('clears resolved flags on all runners', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
|
||||
store.clearResolved()
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns true when task execution succeeds', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when task execution fails', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(false)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(true)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(false)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets runner executing to true during task execution', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(basicTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).executing).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('clears executing flag when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow('fail')
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('sets runner error message when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(
|
||||
new Error('something broke')
|
||||
)
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
expect(store.getRunner(basicTask).error).toBe('something broke')
|
||||
})
|
||||
|
||||
it('clears runner error on successful execution', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningTerminalCommand', () => {
|
||||
it('returns true while a terminal task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningTerminalCommand).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no terminal tasks are executing', () => {
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningInstallationFix', () => {
|
||||
it('returns true while an installation-fix task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningInstallationFix).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -1,52 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
NetWork: {
|
||||
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
|
||||
describe('checkMirrorReachable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false for an invalid URL without calling canAccessUrl', async () => {
|
||||
const result = await checkMirrorReachable('not-a-url')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when canAccessUrl returns false', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when URL is valid and canAccessUrl returns true', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('passes the mirror URL to canAccessUrl', async () => {
|
||||
const url = 'https://pypi.org/simple/'
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
await checkMirrorReachable(url)
|
||||
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
|
||||
})
|
||||
|
||||
it('returns false for empty string', async () => {
|
||||
const result = await checkMirrorReachable('')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
describe('isElectron', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when window.electronAPI is an object', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: {} })
|
||||
expect(isElectron()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is undefined', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: undefined })
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is absent', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeWindow', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: true }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when not in Electron', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay.visible is false', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: false }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay is absent', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: { ...window.navigator, windowControlsOverlay: undefined }
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
function withSetup<T>(composable: () => T): T {
|
||||
let result!: T
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = composable()
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useMinLoadingDurationRef', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflects false when source is initially false', () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects true when source is initially true', () => {
|
||||
const source = ref(true)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes true immediately when source transitions to true', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
source.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true within minDuration after source returns to false', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes false after minDuration has elapsed', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(250)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('remains true while source is true even after minDuration elapses', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('works with a computed ref as input', async () => {
|
||||
const raw = ref(false)
|
||||
const source = computed(() => raw.value)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
raw.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 250ms as default minDuration', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(249)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,7 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.mts"
|
||||
"vite.config.mts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src'),
|
||||
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
setupFiles: ['./src/test/setup.ts']
|
||||
}
|
||||
})
|
||||
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_a.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_b.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model_a.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
{
|
||||
"name": "fake_model_b.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-bypassed-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
@@ -336,9 +336,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<number>(requestAnimationFrame)
|
||||
})
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
@@ -393,6 +391,27 @@ export class ComfyPage {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
async expectScreenshot(
|
||||
locator: Locator,
|
||||
name: string | string[],
|
||||
options?: {
|
||||
animations?: 'disabled' | 'allow'
|
||||
caret?: 'hide' | 'initial'
|
||||
mask?: Array<Locator>
|
||||
maskColor?: string
|
||||
maxDiffPixelRatio?: number
|
||||
maxDiffPixels?: number
|
||||
omitBackground?: boolean
|
||||
scale?: 'css' | 'device'
|
||||
stylePath?: string | Array<string>
|
||||
threshold?: number
|
||||
timeout?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.nextFrame()
|
||||
await comfyExpect(locator).toHaveScreenshot(name, options)
|
||||
}
|
||||
|
||||
async setFocusMode(focusMode: boolean) {
|
||||
await this.page.evaluate((focusMode) => {
|
||||
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode
|
||||
|
||||
@@ -139,6 +139,27 @@ export class Topbar {
|
||||
await this.menuLocator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Nodes 2.0 on or off via the Comfy logo menu switch (no-op if already
|
||||
* in the requested state).
|
||||
*/
|
||||
async setVueNodesEnabled(enabled: boolean) {
|
||||
await this.openTopbarMenu()
|
||||
const nodes2Switch = this.page.getByRole('switch', { name: 'Nodes 2.0' })
|
||||
await nodes2Switch.waitFor({ state: 'visible' })
|
||||
if ((await nodes2Switch.isChecked()) !== enabled) {
|
||||
await nodes2Switch.click()
|
||||
await this.page.waitForFunction(
|
||||
(wantEnabled) =>
|
||||
window.app!.ui.settings.getSettingValue('Comfy.VueNodes.Enabled') ===
|
||||
wantEnabled,
|
||||
enabled,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
await this.closeTopbarMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a submenu by hovering over a menu item
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,17 @@ export class AppModeHelper {
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The "Switch to Outputs" button inside the connect-output popover. */
|
||||
public readonly connectOutputSwitchButton: Locator
|
||||
/** The empty-workflow dialog shown when entering builder on an empty graph. */
|
||||
public readonly emptyWorkflowDialog: Locator
|
||||
/** "Back to workflow" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowBackButton: Locator
|
||||
/** "Load template" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowLoadTemplateButton: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
@@ -39,6 +48,18 @@ export class AppModeHelper {
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
public readonly arrangePreview: Locator
|
||||
/** Arrange-step state shown when no outputs have been configured. */
|
||||
public readonly arrangeNoOutputs: Locator
|
||||
/** "Switch to Outputs" button inside the arrange no-outputs state. */
|
||||
public readonly arrangeSwitchToOutputsButton: Locator
|
||||
/** The Vue Node switch notification popup shown on entering builder. */
|
||||
public readonly vueNodeSwitchPopup: Locator
|
||||
/** The "Dismiss" button inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
@@ -47,9 +68,22 @@ export class AppModeHelper {
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.connectOutputSwitchButton = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputSwitch
|
||||
)
|
||||
this.emptyWorkflowDialog = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowDialog
|
||||
)
|
||||
this.emptyWorkflowBackButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowBack
|
||||
)
|
||||
this.emptyWorkflowLoadTemplateButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowLoadTemplate
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
@@ -75,6 +109,22 @@ export class AppModeHelper {
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
)
|
||||
this.arrangePreview = this.page.getByTestId(TestIds.appMode.arrangePreview)
|
||||
this.arrangeNoOutputs = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeNoOutputs
|
||||
)
|
||||
this.arrangeSwitchToOutputsButton = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeSwitchToOutputs
|
||||
)
|
||||
this.vueNodeSwitchPopup = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchPopup
|
||||
)
|
||||
this.vueNodeSwitchDismissButton = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDismiss
|
||||
)
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -92,8 +142,33 @@ export class AppModeHelper {
|
||||
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
|
||||
/** Set preference so the Vue node switch popup does not appear in builder. */
|
||||
async suppressVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Allow the Vue node switch popup so tests can assert its behavior. */
|
||||
async allowVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
// Wait for any workflow-tab popover to dismiss before clicking —
|
||||
// the popover overlay can intercept the "Workflow actions" click.
|
||||
// Best-effort: the popover may or may not exist; if it stays visible
|
||||
// past the timeout we still proceed with the click.
|
||||
await this.page
|
||||
.locator('.workflow-popover-fade')
|
||||
.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
.catch(() => {})
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
@@ -108,7 +183,6 @@ export class AppModeHelper {
|
||||
async toggleAppMode() {
|
||||
await this.comfyPage.workflow.waitForActiveWorkflow()
|
||||
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,18 +13,30 @@ export class BuilderStepsHelper {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get inputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Inputs' })
|
||||
}
|
||||
|
||||
get outputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Outputs' })
|
||||
}
|
||||
|
||||
get previewButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Preview' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.inputsButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.outputsButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.previewButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class CanvasHelper {
|
||||
constructor(
|
||||
@@ -10,18 +11,12 @@ export class CanvasHelper {
|
||||
private resetViewButton: Locator
|
||||
) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<number>(requestAnimationFrame)
|
||||
})
|
||||
}
|
||||
|
||||
async resetView(): Promise<void> {
|
||||
if (await this.resetViewButton.isVisible()) {
|
||||
await this.resetViewButton.click()
|
||||
}
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async zoom(deltaY: number, steps: number = 1): Promise<void> {
|
||||
@@ -29,7 +24,7 @@ export class CanvasHelper {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.wheel(0, deltaY)
|
||||
}
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async pan(offset: Position, safeSpot?: Position): Promise<void> {
|
||||
@@ -38,7 +33,7 @@ export class CanvasHelper {
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
|
||||
@@ -56,22 +51,22 @@ export class CanvasHelper {
|
||||
type: 'touchEnd',
|
||||
touchPoints: []
|
||||
})
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async rightClick(x: number = 10, y: number = 10): Promise<void> {
|
||||
await this.page.mouse.click(x, y, { button: 'right' })
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async doubleClick(): Promise<void> {
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async click(position: Position): Promise<void> {
|
||||
await this.canvas.click({ position })
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +102,7 @@ export class CanvasHelper {
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,12 +111,12 @@ export class CanvasHelper {
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async dragAndDrop(source: Position, target: Position): Promise<void> {
|
||||
@@ -129,7 +124,7 @@ export class CanvasHelper {
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async moveMouseToEmptyArea(): Promise<void> {
|
||||
@@ -152,7 +147,7 @@ export class CanvasHelper {
|
||||
await this.page.evaluate((s) => {
|
||||
window.app!.canvas.ds.scale = s
|
||||
}, scale)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async convertOffsetToCanvas(
|
||||
@@ -236,12 +231,12 @@ export class CanvasHelper {
|
||||
// Sweep forward
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
// Sweep back
|
||||
for (let i = steps; i > 0; i--) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { KeyCombo } from '@/platform/keybindings/types'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class CommandHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
@@ -20,6 +21,7 @@ export class CommandHelper {
|
||||
},
|
||||
{ commandId, metadata }
|
||||
)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async registerCommand(
|
||||
|
||||
@@ -5,18 +5,11 @@ import type { Page } from '@playwright/test'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
@@ -145,7 +138,7 @@ export class DragDropHelper {
|
||||
await uploadResponsePromise
|
||||
}
|
||||
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async dragAndDropFile(
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class KeyboardHelper {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly canvas: Locator
|
||||
) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
async press(key: string, locator?: Locator | null): Promise<void> {
|
||||
const target = locator ?? this.canvas
|
||||
await target.press(key)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async delete(locator?: Locator | null): Promise<void> {
|
||||
await this.press('Delete', locator)
|
||||
}
|
||||
|
||||
async ctrlSend(
|
||||
@@ -16,7 +24,7 @@ export class KeyboardHelper {
|
||||
): Promise<void> {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Control+${keyToPress}`)
|
||||
await this.nextFrame()
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async selectAll(locator?: Locator | null): Promise<void> {
|
||||
|
||||
@@ -140,13 +140,11 @@ export class NodeOperationsHelper {
|
||||
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
|
||||
target
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
if (revertAfter) {
|
||||
await this.comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: target.x - 2, y: target.y - 1 },
|
||||
bottomRight
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +156,6 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
@@ -192,7 +189,6 @@ export class NodeOperationsHelper {
|
||||
y: 300
|
||||
}
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async adjustEmptyLatentWidth(): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class SettingsHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -10,6 +12,7 @@ export class SettingsHelper {
|
||||
},
|
||||
{ id: settingId, value: settingValue }
|
||||
)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async getSetting<T = unknown>(settingId: string): Promise<T> {
|
||||
|
||||
@@ -465,11 +465,7 @@ export class SubgraphHelper {
|
||||
const serialized = await this.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await this.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized as ComfyWorkflowJSON
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
|
||||
}
|
||||
|
||||
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
|
||||
@@ -477,14 +473,12 @@ export class SubgraphHelper {
|
||||
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await this.comfyPage.nextFrame()
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
|
||||
@@ -70,10 +70,19 @@ export class WorkflowHelper {
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.comfyPage.page.evaluate(
|
||||
(wf) => window.app!.loadGraphData(wf),
|
||||
workflow
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.waitForWorkflowIdle()
|
||||
await this.comfyPage.nextFrame()
|
||||
if (test.info().tags.includes('@vue-nodes')) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
@@ -137,7 +137,11 @@ export const TestIds = {
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label',
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
connectOutputPopover: 'builder-connect-output-popover',
|
||||
connectOutputSwitch: 'builder-connect-output-switch',
|
||||
emptyWorkflowDialog: 'builder-empty-workflow-dialog',
|
||||
emptyWorkflowBack: 'builder-empty-workflow-back',
|
||||
emptyWorkflowLoadTemplate: 'builder-empty-workflow-load-template'
|
||||
},
|
||||
outputHistory: {
|
||||
outputs: 'linear-outputs',
|
||||
@@ -163,7 +167,13 @@ export const TestIds = {
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template'
|
||||
loadTemplate: 'linear-welcome-load-template',
|
||||
arrangePreview: 'linear-arrange-preview',
|
||||
arrangeNoOutputs: 'linear-arrange-no-outputs',
|
||||
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
|
||||
vueNodeSwitchPopup: 'linear-vue-node-switch-popup',
|
||||
vueNodeSwitchDismiss: 'linear-vue-node-switch-dismiss',
|
||||
vueNodeSwitchDontShowAgain: 'linear-vue-node-switch-dont-show-again'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -188,6 +198,16 @@ export const TestIds = {
|
||||
},
|
||||
load3dViewer: {
|
||||
sidebar: 'load3d-viewer-sidebar'
|
||||
},
|
||||
imageCompare: {
|
||||
viewport: 'image-compare-viewport',
|
||||
empty: 'image-compare-empty',
|
||||
batchNav: 'batch-nav',
|
||||
beforeBatch: 'before-batch',
|
||||
afterBatch: 'after-batch',
|
||||
batchCounter: 'batch-counter',
|
||||
batchNext: 'batch-next',
|
||||
batchPrev: 'batch-prev'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -221,3 +241,4 @@ export type TestIdValue =
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]
|
||||
|
||||
@@ -388,7 +388,6 @@ export class NodeReference {
|
||||
async copy() {
|
||||
await this.click('title')
|
||||
await this.comfyPage.clipboard.copy()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
await this.click('title')
|
||||
@@ -434,7 +433,6 @@ export class NodeReference {
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function nextFrame(page: Page): Promise<number> {
|
||||
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
}
|
||||
|
||||
70
browser_tests/tests/appModeArrange.spec.ts
Normal file
70
browser_tests/tests/appModeArrange.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Placeholder is shown when outputs are configured but no run has happened', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupBuilder(comfyPage)
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.steps.previewButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangePreview).toBeVisible()
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('No-outputs state navigates to the Outputs step via "Switch to Outputs"', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.arrangeNoOutputs).toBeVisible()
|
||||
await expect(appMode.arrangePreview).toBeHidden()
|
||||
|
||||
await appMode.arrangeSwitchToOutputsButton.click()
|
||||
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('Connect-output popover from preview step navigates to the Outputs step', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
// From a non-select step (preview/arrange), the popover surfaces a
|
||||
// "Switch to Outputs" shortcut alongside cancel.
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeVisible()
|
||||
await expect(appMode.connectOutputSwitchButton).toBeVisible()
|
||||
|
||||
await appMode.connectOutputSwitchButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeHidden()
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
})
|
||||
})
|
||||
84
browser_tests/tests/appModeVueNodeSwitch.spec.ts
Normal file
84
browser_tests/tests/appModeVueNodeSwitch.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function enterBuilderExpectVueNodeSwitchPopup(comfyPage: ComfyPage) {
|
||||
const { appMode } = comfyPage
|
||||
await appMode.enterBuilder()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectVueNodesEnabled(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>('Comfy.VueNodes.Enabled')
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Vue node switch notification popup', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.allowVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Popup appears when entering builder; dismiss closes without persisting and shows again on a later entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
// "Don't show again" was not checked → preference remains false
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Don\'t show again" persists the dismissal and suppresses future popups', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
|
||||
// Dismiss with dont show again checked
|
||||
await appMode.vueNodeSwitchDontShowAgainCheckbox.check()
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
@@ -58,4 +59,37 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowBackButton).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowLoadTemplateButton).toBeVisible()
|
||||
|
||||
// Back to workflow dismisses the dialog and returns to graph mode
|
||||
await appMode.emptyWorkflowBackButton.click()
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog "Load template" opens the template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await appMode.emptyWorkflowLoadTemplateButton.click()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,7 +157,10 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
|
||||
test('Reordering inputs in one app does not corrupt another app', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
|
||||
// times — the default 15s timeout is insufficient in CI.
|
||||
testInfo.setTimeout(45_000)
|
||||
const { appMode } = comfyPage
|
||||
const app2Widgets = ['seed', 'steps']
|
||||
const app1Reordered = ['steps', 'cfg', 'seed']
|
||||
|
||||
@@ -110,8 +110,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -29,7 +29,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Trigger button', () => {
|
||||
@@ -46,7 +45,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(mode.iconPattern)
|
||||
@@ -103,7 +101,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
}) => {
|
||||
if (!mode.isReadOnly) {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
@@ -156,7 +153,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
@@ -208,7 +204,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
@@ -229,8 +224,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts unlocked'
|
||||
).toBe(false)
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyH')
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
@@ -241,13 +235,11 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts locked'
|
||||
).toBe(true)
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyV')
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
|
||||
@@ -223,8 +223,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await beforeChange(comfyPage)
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect(node).toBeBypassed()
|
||||
await comfyPage.page.keyboard.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await expect(node).toBePinned()
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
|
||||
@@ -74,18 +74,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
return node!.id
|
||||
})
|
||||
|
||||
// Wait for the asset widget to mount AND its value to resolve.
|
||||
// The widget type becomes 'asset' before the value is populated,
|
||||
// so poll for both conditions together to avoid a race where the
|
||||
// type check passes but the value is still the placeholder.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await comfyPage.page.evaluate((id) => {
|
||||
() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
}, nodeId)
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
|
||||
@@ -157,18 +157,15 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark-all-colors.png'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-light-red.png'
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
|
||||
})
|
||||
|
||||
@@ -181,7 +178,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark.png'
|
||||
)
|
||||
@@ -190,7 +186,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
'Comfy.ColorPalette',
|
||||
'custom_obsidian_dark'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark.png'
|
||||
)
|
||||
@@ -212,15 +207,12 @@ test.describe(
|
||||
|
||||
// Drag mouse to force canvas to redraw
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
|
||||
await comfyPage.page.mouse.move(8, 8)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing themes', async ({
|
||||
@@ -229,8 +221,8 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'node-opacity-0.2-arc-theme.png'
|
||||
)
|
||||
})
|
||||
@@ -240,7 +232,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
@@ -279,7 +270,6 @@ test.describe(
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-lightened-colors.png'
|
||||
)
|
||||
|
||||
@@ -155,7 +155,6 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
|
||||
@@ -52,8 +52,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Alt+=' zooms in", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Alt+Equal')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -63,8 +62,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Alt+Minus')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -82,8 +80,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Period')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -93,8 +90,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyH')
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
@@ -102,11 +98,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyV')
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
@@ -121,16 +115,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Alt+KeyC')
|
||||
await expect.poll(() => node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Alt+KeyC')
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -140,7 +131,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
const getMode = () =>
|
||||
@@ -150,13 +140,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+KeyM')
|
||||
// NEVER (2) = muted
|
||||
await expect.poll(() => getMode()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+KeyM')
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -239,16 +227,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
|
||||
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
|
||||
@@ -265,8 +251,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+o')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
@@ -288,11 +273,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+Shift+KeyE')
|
||||
|
||||
// After conversion, node count should decrease
|
||||
// (multiple nodes replaced by single subgraph node)
|
||||
|
||||
@@ -145,15 +145,27 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
// Open the dropdown via its combobox role and verify it expanded.
|
||||
// Retry because the PrimeVue Select may re-render during search
|
||||
// filtering, causing the first click to land on a stale element.
|
||||
// Wait for the search filter to fully settle — PrimeVue re-renders
|
||||
// the entire settings list after typing, and the combobox element is
|
||||
// replaced during re-render. Wait until the filtered list stabilises
|
||||
// before interacting with the combobox.
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
await expect
|
||||
.poll(() => settingItems.count(), { timeout: 5000 })
|
||||
.toBeLessThanOrEqual(5)
|
||||
|
||||
const select = settingRow.getByRole('combobox')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toBeEnabled()
|
||||
|
||||
// Open the dropdown via its combobox role and verify it expanded.
|
||||
// Retry because the PrimeVue Select may still re-render after the
|
||||
// filter settles, causing the first click to land on a stale element.
|
||||
await expect(async () => {
|
||||
const expanded = await select.getAttribute('aria-expanded')
|
||||
if (expanded !== 'true') await select.click()
|
||||
await expect(select).toHaveAttribute('aria-expanded', 'true')
|
||||
}).toPass({ timeout: 5000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
// Pick the option that is not the current value
|
||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||
|
||||
@@ -214,4 +214,34 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Count independence from node selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('missing model count stays constant when a node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
TestIds.canvas.toggleLinkVisibilityButton
|
||||
)
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'canvas-with-hidden-links.png'
|
||||
)
|
||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
.toBe(hiddenLinkRenderMode)
|
||||
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'canvas-with-visible-links.png'
|
||||
)
|
||||
await expect
|
||||
|
||||
@@ -170,7 +170,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'groupnodes/group_node_identical_nodes_hidden_inputs'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupNodeId = 19
|
||||
const groupNodeName = 'two_VAE_decode'
|
||||
@@ -336,12 +335,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
)
|
||||
|
||||
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow) =>
|
||||
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
|
||||
currentGraphState
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
currentGraphState as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
@@ -84,7 +83,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
@@ -107,7 +105,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the outer group (cascades to children)
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -87,10 +88,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -98,7 +95,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
|
||||
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.getByRole('presentation')).toHaveCount(0)
|
||||
}
|
||||
@@ -126,10 +123,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -164,10 +157,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
@@ -183,7 +172,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const handle = node.getByRole('presentation')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
await expect(afterImg).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
@@ -224,7 +213,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
await expect(afterImg).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
@@ -261,7 +250,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const compareArea = node.getByTestId('image-compare-viewport')
|
||||
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
await expect(compareArea).toBeVisible()
|
||||
|
||||
await expect
|
||||
@@ -292,10 +281,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBeCloseTo(100, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -324,10 +309,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
@@ -342,13 +323,21 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
|
||||
await expect(node.getByTestId('batch-nav')).toBeVisible()
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
|
||||
await expect(
|
||||
node.getByTestId(TestIds.imageCompare.batchNav)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(node.getByTestId('after-batch')).toBeHidden()
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
|
||||
await expect(
|
||||
node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -362,7 +351,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.getByTestId('batch-nav')).toBeHidden()
|
||||
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -378,10 +367,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
@@ -407,10 +396,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
@@ -436,14 +425,18 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
|
||||
await beforeBatch.getByTestId('batch-next').click()
|
||||
await afterBatch.getByTestId('batch-next').click()
|
||||
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
|
||||
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
|
||||
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('2 / 3')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
@@ -454,11 +447,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
|
||||
const minWidth = 400
|
||||
const minHeight = 350
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
if (!graphNode?.size) return null
|
||||
@@ -472,17 +463,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(
|
||||
size.width,
|
||||
'ImageCompare node minimum width'
|
||||
).toBeGreaterThanOrEqual(400)
|
||||
).toBeGreaterThanOrEqual(minWidth)
|
||||
expect(
|
||||
size.height,
|
||||
'ImageCompare node minimum height'
|
||||
).toBeGreaterThanOrEqual(350)
|
||||
).toBeGreaterThanOrEqual(minHeight)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||
@@ -500,7 +487,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
await waitForImagesLoaded(node)
|
||||
await expect(viewport).toBeVisible()
|
||||
await moveToPercentage(comfyPage.page, viewport, pct)
|
||||
@@ -516,10 +503,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget handles image load failure gracefully', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -586,9 +569,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await node.getByTestId('before-batch').getByTestId('batch-next').click()
|
||||
await node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchNext)
|
||||
.click()
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('2 / 2')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
@@ -601,7 +589,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
@@ -656,23 +646,35 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 20')
|
||||
|
||||
const beforeNext = beforeBatch.getByTestId('batch-next')
|
||||
const afterNext = afterBatch.getByTestId('batch-next')
|
||||
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
for (let i = 0; i < 19; i++) {
|
||||
await beforeNext.click()
|
||||
await afterNext.click()
|
||||
}
|
||||
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
|
||||
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('20 / 20')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('20 / 20')
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeEnabled()
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeEnabled()
|
||||
await expect(beforeNext).toBeDisabled()
|
||||
await expect(afterNext).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -31,11 +31,9 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
|
||||
})
|
||||
})
|
||||
@@ -76,13 +74,11 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode2
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,8 +170,7 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
@@ -185,7 +180,6 @@ test.describe('Node Interaction', () => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
// LGraphCanvas, not the Vue node drag handler.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
|
||||
@@ -285,7 +279,6 @@ test.describe('Node Interaction', () => {
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
|
||||
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
|
||||
@@ -359,8 +352,8 @@ test.describe('Node Interaction', () => {
|
||||
modifiers: ['Control', 'Alt'],
|
||||
position: loadCheckpointClipSlotPos
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'batch-disconnect-links-disconnected.png'
|
||||
)
|
||||
}
|
||||
@@ -410,8 +403,8 @@ test.describe('Node Interaction', () => {
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
|
||||
// Move mouse away to avoid hover highlight differences.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'text-encode-toggled-back-open.png'
|
||||
)
|
||||
}
|
||||
@@ -514,8 +507,7 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
// Confirm group title
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-selected-nodes.png'
|
||||
)
|
||||
@@ -1171,8 +1163,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'legacy-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
@@ -1180,14 +1172,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'legacy-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'legacy-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
@@ -1247,8 +1239,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
@@ -1258,16 +1250,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-ctrl-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-ctrl-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
@@ -1359,33 +1351,31 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
|
||||
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-shift-wheel-pan-right.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, -240)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-shift-wheel-pan-left.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'standard-shift-wheel-pan-center.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -112,9 +112,8 @@ test.describe('Load3D', () => {
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
load3d.node,
|
||||
'load3d-uploaded-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
@@ -142,9 +141,8 @@ test.describe('Load3D', () => {
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
load3d.node,
|
||||
'load3d-dropped-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
|
||||
@@ -143,8 +143,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'mobile-empty-canvas.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
@@ -24,7 +26,6 @@ test.describe(
|
||||
|
||||
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const minimapButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
@@ -38,9 +39,8 @@ test.describe(
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.settingDialog.root,
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
|
||||
@@ -13,7 +13,6 @@ test.describe(
|
||||
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) {
|
||||
@@ -35,7 +34,6 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
|
||||
async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
@@ -53,7 +52,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
test('positions ghost node at cursor', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
@@ -110,8 +108,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
@@ -124,8 +121,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Delete')
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
@@ -138,8 +134,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Backspace')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Backspace')
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
|
||||
@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'link-release-context-menu.png'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,40 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: on un-bypass, the realtime scan produced a fresh
|
||||
// candidate without url/hash/directory — those fields were only
|
||||
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
|
||||
// row's Copy URL button (v-if gated on representative.url) then
|
||||
// disappeared. Per-node scan now enriches from node.properties.models
|
||||
// which persists across mode toggles. Uses the `_from_node_properties`
|
||||
// fixture because the enrichment source is per-node metadata, not
|
||||
// the workflow-level `models[]` array (which the realtime scan
|
||||
// path does not see).
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a node with missing model increases referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -476,6 +510,52 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: the initial scan pipeline only checked each node's
|
||||
// own mode, so interior nodes of a bypassed subgraph container
|
||||
// surfaced errors even though the container was excluded from
|
||||
// execution. The pipeline now post-filters candidates whose
|
||||
// ancestor path is not fully active.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: useGraphNodeManager replays graph.onNodeAdded for
|
||||
// each interior node on subgraph entry; without an ancestor-aware
|
||||
// guard in scanSingleNodeErrors, that re-scan reintroduced the
|
||||
// error that the initial pipeline had correctly suppressed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflow switching', () => {
|
||||
|
||||
@@ -19,8 +19,10 @@ test.describe(
|
||||
await comfyPage.page.getByText('loaders').click()
|
||||
await comfyPage.page.getByText('Load VAE').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'add-node-node-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can add group', async ({ comfyPage }) => {
|
||||
@@ -28,8 +30,8 @@ test.describe(
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Group', { exact: true }).click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
@@ -45,8 +47,8 @@ test.describe(
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
@@ -60,12 +62,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.page.getByText('Properties Panel').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-properties-panel.png'
|
||||
)
|
||||
})
|
||||
@@ -76,12 +77,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.page.getByText('Collapse').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-collapsed.png'
|
||||
)
|
||||
})
|
||||
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('Collapse').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-collapsed-badge.png'
|
||||
)
|
||||
})
|
||||
@@ -116,12 +116,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.page.getByText('Bypass').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-bypassed.png'
|
||||
)
|
||||
})
|
||||
@@ -133,8 +132,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -149,8 +147,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-pinned-node.png'
|
||||
)
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
|
||||
@@ -160,8 +158,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-unpinned-node.png'
|
||||
)
|
||||
})
|
||||
@@ -206,8 +204,10 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'selected-nodes-pinned.png'
|
||||
)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick,
|
||||
button: 'right'
|
||||
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'selected-nodes-unpinned.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,15 +11,13 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
|
||||
const totalCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Use canvas press for keyboard shortcuts (doesn't need click target)
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
|
||||
})
|
||||
|
||||
test('Click empty space deselects all', async ({ comfyPage }) => {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
|
||||
|
||||
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
|
||||
@@ -70,8 +68,7 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
// Use Ctrl+A to select all, which is functionally equivalent to
|
||||
// drag-selecting the entire canvas and more reliable in CI
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.vueNodes.getNodeCount())
|
||||
|
||||
@@ -267,8 +267,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
await comfyPage.page.keyboard.press('Control+Z')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Control+Z')
|
||||
|
||||
// Node should be uncolored again
|
||||
const selectedNode = (
|
||||
|
||||
@@ -32,7 +32,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('delete button removes selected node', async ({ comfyPage }) => {
|
||||
@@ -69,7 +68,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
@@ -83,7 +81,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
@@ -160,7 +157,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
@@ -187,7 +183,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
@@ -229,7 +224,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the SaveImage node by panning to it
|
||||
const saveImageRef = (
|
||||
|
||||
@@ -14,7 +14,6 @@ test.describe(
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
@@ -43,7 +42,6 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ test.describe('Sidebar splitter width independence', () => {
|
||||
location: 'left' | 'right'
|
||||
) {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
|
||||
await comfyPage.nextFrame()
|
||||
await dismissToasts(comfyPage)
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
|
||||
@@ -37,7 +37,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
@@ -49,8 +48,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
|
||||
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
@@ -64,8 +62,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Enter')
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
@@ -78,7 +75,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const backButton = breadcrumb.locator('.back-button')
|
||||
@@ -90,13 +86,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect(backButton).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
await expect(backButton).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
await expect(backButton).toHaveCount(0)
|
||||
@@ -106,7 +100,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
@@ -135,7 +128,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -145,8 +137,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
@@ -154,8 +145,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Alt+q')
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -163,7 +153,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -183,8 +172,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
@@ -192,8 +180,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -205,7 +192,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -272,7 +258,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
@@ -296,8 +281,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -312,7 +296,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
@@ -328,10 +311,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -18,7 +18,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
@@ -38,13 +38,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+c')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('ControlOrMeta+c')
|
||||
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+v')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('ControlOrMeta+v')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
|
||||
@@ -102,7 +102,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -150,7 +149,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
@@ -318,7 +316,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
@@ -403,7 +400,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
@@ -455,7 +451,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
@@ -476,7 +471,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
|
||||
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
|
||||
@@ -68,7 +68,6 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
@@ -88,8 +87,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
|
||||
@@ -15,8 +15,7 @@ async function exitSubgraphAndPublish(
|
||||
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
|
||||
blueprintName: string
|
||||
) {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
|
||||
@@ -40,7 +40,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
@@ -59,7 +58,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -73,20 +71,17 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
@@ -121,7 +116,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
|
||||
@@ -424,7 +424,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await subgraphNodeRef.navigateIntoSubgraph()
|
||||
|
||||
@@ -34,9 +34,8 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
|
||||
{ message: 'All nodes should be within the visible viewport' }
|
||||
)
|
||||
.toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'viewport-fits-when-saved-offscreen.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
})
|
||||
@@ -131,7 +131,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
|
||||
@@ -24,8 +24,8 @@ test.describe('Vue Node Bypass', { tag: '@vue-nodes' }, () => {
|
||||
.filter({ hasText: 'Load Checkpoint' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
|
||||
@@ -5,17 +5,19 @@ import {
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_OPACITY = '0.5'
|
||||
const SELECTED_CLASS = /outline-node-component-outline/
|
||||
|
||||
test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'should allow toggling mute on a selected node with hotkey',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-muted-state.png'
|
||||
@@ -29,12 +31,14 @@ test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await expect(ksamplerNode).toHaveClass(SELECTED_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -13,8 +10,8 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
|
||||
'reroute node visual appearance',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-reroute-node-compact.png'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,6 @@ test.describe('Workflow Persistence', () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
|
||||
|
||||
@@ -289,10 +288,8 @@ test.describe('Workflow Persistence', () => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
@@ -349,7 +346,6 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B: duplicate, add a node, then save (unmodified after save)
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
@@ -410,7 +406,6 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
@@ -487,7 +482,6 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
|
||||
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,12 @@
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
data-testid="builder-connect-output-switch"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
{{ t('builderToolbar.switchToOutputs') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<BuilderDialog :show-close="false">
|
||||
<BuilderDialog
|
||||
data-testid="builder-empty-workflow-dialog"
|
||||
:show-close="false"
|
||||
>
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.emptyWorkflowTitle') }}
|
||||
</template>
|
||||
@@ -17,11 +20,17 @@
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-back"
|
||||
@click="$emit('backToWorkflow')"
|
||||
>
|
||||
{{ $t('linearMode.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-load-template"
|
||||
@click="$emit('loadTemplate')"
|
||||
>
|
||||
{{ $t('linearMode.loadTemplate') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
data-testid="linear-vue-node-switch-popup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@@ -15,6 +16,7 @@
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
data-testid="linear-vue-node-switch-dont-show-again"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
@@ -25,6 +27,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="linear-vue-node-switch-dismiss"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
|
||||
@@ -293,8 +293,8 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -754,4 +756,48 @@ describe('useErrorGroups', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +833,10 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups: filteredMissingModelGroups,
|
||||
missingMediaGroups: filteredMissingMediaGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +728,109 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
|
||||
// Workflow A has a pending candidate on node id=1. A is replaced
|
||||
// by workflow B (fresh LGraph, potentially has a node with the
|
||||
// same id). Late verification from A must not leak into B.
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'stale_from_A.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
// Workflow swap: app.rootGraph now points at graphB.
|
||||
const graphB = new LGraph()
|
||||
const nodeB = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphB.add(nodeB)
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// A's verification finished but rootGraph is now B — the late
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not surface interior missing model when entering a bypassed subgraph', async () => {
|
||||
// Repro: root has a bypassed subgraph container, interior node is
|
||||
// itself active. useGraphNodeManager replays `onNodeAdded` for each
|
||||
// interior node on subgraph entry, which previously reached
|
||||
// scanSingleNodeErrors without an ancestor check and resurfaced the
|
||||
// error that the initial pipeline post-filter had correctly dropped.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode.mode = LGraphEventMode.BYPASS
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
// Any scanner output would surface the error if the ancestor guard
|
||||
// didn't short-circuit first — return a concrete missing candidate.
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(subgraph)
|
||||
|
||||
// Simulate useGraphNodeManager replaying onNodeAdded for existing
|
||||
// interior nodes after Vue node manager init on subgraph entry.
|
||||
subgraph.onNodeAdded?.(interiorNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph,
|
||||
getNodeByExecutionId
|
||||
getNodeByExecutionId,
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
@@ -172,6 +173,14 @@ function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
// Skip when any enclosing subgraph is muted/bypassed. Callers only
|
||||
// verify each node's own mode; entering a bypassed subgraph (via
|
||||
// useGraphNodeManager replaying onNodeAdded for existing interior
|
||||
// nodes) reaches this point without the ancestor check. A null
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
@@ -237,16 +246,27 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
*/
|
||||
function isCandidateStillActive(nodeId: unknown): boolean {
|
||||
if (!app.rootGraph || nodeId == null) return false
|
||||
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
|
||||
const execId = String(nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (!node) return false
|
||||
return !isNodeInactive(node.mode)
|
||||
if (isNodeInactive(node.mode)) return false
|
||||
// Also reject if any enclosing subgraph was bypassed between scan
|
||||
// kick-off and verification resolving — mirrors the pipeline-level
|
||||
// ancestor post-filter so realtime and initial-load paths stay
|
||||
// symmetric.
|
||||
return isAncestorPathActive(app.rootGraph, execId)
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingModels(
|
||||
pending: MissingModelCandidate[]
|
||||
): Promise<void> {
|
||||
// Capture rootGraph at scan time so a late verification for workflow
|
||||
// A cannot leak into workflow B after a switch — execution IDs (esp.
|
||||
// root-level like "1") collide across workflows.
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyAssetSupportedCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
@@ -259,8 +279,10 @@ async function verifyAndAddPendingModels(
|
||||
async function verifyAndAddPendingMedia(
|
||||
pending: MissingMediaCandidate[]
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
|
||||
446
src/composables/painter/usePainter.test.ts
Normal file
446
src/composables/painter/usePainter.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { usePainter } from './usePainter'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementSize: vi.fn(() => ({
|
||||
width: ref(512),
|
||||
height: ref(512)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
|
||||
StrokeProcessor: vi.fn(() => ({
|
||||
addPoint: vi.fn(() => []),
|
||||
endStroke: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => {
|
||||
const store = { addAlert: vi.fn() }
|
||||
return { useToastStore: () => store }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
const store = {
|
||||
getNodeImageUrls: vi.fn(() => undefined),
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
}
|
||||
return { useNodeOutputStore: () => store }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const mockWidgets: IBaseWidget[] = []
|
||||
const mockProperties: Record<string, unknown> = {}
|
||||
const mockIsInputConnected = vi.fn(() => false)
|
||||
const mockGetInputNode = vi.fn(() => null)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph: {
|
||||
getNodeById: vi.fn(() => ({
|
||||
get widgets() {
|
||||
return mockWidgets
|
||||
},
|
||||
get properties() {
|
||||
return mockProperties
|
||||
},
|
||||
isInputConnected: mockIsInputConnected,
|
||||
getInputNode: mockGetInputNode
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
type PainterResult = ReturnType<typeof usePainter>
|
||||
|
||||
function makeWidget(name: string, value: unknown = null): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
callback: vi.fn(),
|
||||
serializeValue: undefined
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
|
||||
*/
|
||||
function mountPainter(nodeId = 'test-node', initialModelValue = '') {
|
||||
let painter!: PainterResult
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const cursorEl = ref<HTMLElement | null>(null)
|
||||
const modelValue = ref(initialModelValue)
|
||||
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
painter = usePainter(nodeId, {
|
||||
canvasEl,
|
||||
cursorEl,
|
||||
modelValue
|
||||
})
|
||||
return {}
|
||||
},
|
||||
render() {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
render(Wrapper)
|
||||
return { painter, canvasEl, cursorEl, modelValue }
|
||||
}
|
||||
|
||||
describe('usePainter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
mockWidgets.length = 0
|
||||
for (const key of Object.keys(mockProperties)) {
|
||||
delete mockProperties[key]
|
||||
}
|
||||
mockIsInputConnected.mockReturnValue(false)
|
||||
mockGetInputNode.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('syncCanvasSizeFromWidgets', () => {
|
||||
it('reads width/height from widget values on initialization', () => {
|
||||
mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768))
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(1024)
|
||||
expect(painter.canvasHeight.value).toBe(768)
|
||||
})
|
||||
|
||||
it('defaults to 512 when widgets are missing', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreSettingsFromProperties', () => {
|
||||
it('restores tool and brush settings from node properties on init', () => {
|
||||
mockProperties.painterTool = 'eraser'
|
||||
mockProperties.painterBrushSize = 42
|
||||
mockProperties.painterBrushColor = '#ff0000'
|
||||
mockProperties.painterBrushOpacity = 0.5
|
||||
mockProperties.painterBrushHardness = 0.8
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.tool.value).toBe('eraser')
|
||||
expect(painter.brushSize.value).toBe(42)
|
||||
expect(painter.brushColor.value).toBe('#ff0000')
|
||||
expect(painter.brushOpacity.value).toBe(0.5)
|
||||
expect(painter.brushHardness.value).toBe(0.8)
|
||||
})
|
||||
|
||||
it('restores backgroundColor from bg_color widget', () => {
|
||||
mockWidgets.push(makeWidget('bg_color', '#123456'))
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.backgroundColor.value).toBe('#123456')
|
||||
})
|
||||
|
||||
it('keeps defaults when no properties are stored', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.tool.value).toBe('brush')
|
||||
expect(painter.brushSize.value).toBe(20)
|
||||
expect(painter.brushColor.value).toBe('#ffffff')
|
||||
expect(painter.brushOpacity.value).toBe(1)
|
||||
expect(painter.brushHardness.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSettingsToProperties', () => {
|
||||
it('persists tool settings to node properties when they change', async () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.tool.value = 'eraser'
|
||||
painter.brushSize.value = 50
|
||||
painter.brushColor.value = '#00ff00'
|
||||
painter.brushOpacity.value = 0.7
|
||||
painter.brushHardness.value = 0.3
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockProperties.painterTool).toBe('eraser')
|
||||
expect(mockProperties.painterBrushSize).toBe(50)
|
||||
expect(mockProperties.painterBrushColor).toBe('#00ff00')
|
||||
expect(mockProperties.painterBrushOpacity).toBe(0.7)
|
||||
expect(mockProperties.painterBrushHardness).toBe(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncCanvasSizeToWidgets', () => {
|
||||
it('syncs canvas dimensions to widgets when size changes', async () => {
|
||||
const widthWidget = makeWidget('width', 512)
|
||||
const heightWidget = makeWidget('height', 512)
|
||||
mockWidgets.push(widthWidget, heightWidget)
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.canvasWidth.value = 800
|
||||
painter.canvasHeight.value = 600
|
||||
await nextTick()
|
||||
|
||||
expect(widthWidget.value).toBe(800)
|
||||
expect(heightWidget.value).toBe(600)
|
||||
expect(widthWidget.callback).toHaveBeenCalledWith(800)
|
||||
expect(heightWidget.callback).toHaveBeenCalledWith(600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncBackgroundColorToWidget', () => {
|
||||
it('syncs background color to widget when color changes', async () => {
|
||||
const bgWidget = makeWidget('bg_color', '#000000')
|
||||
mockWidgets.push(bgWidget)
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.backgroundColor.value = '#ff00ff'
|
||||
await nextTick()
|
||||
|
||||
expect(bgWidget.value).toBe('#ff00ff')
|
||||
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputImageUrl', () => {
|
||||
it('sets isImageInputConnected to false when input is not connected', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.isImageInputConnected.value).toBe(false)
|
||||
expect(painter.inputImageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('sets isImageInputConnected to true when input is connected', () => {
|
||||
mockIsInputConnected.mockReturnValue(true)
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.isImageInputConnected.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputImageLoad', () => {
|
||||
it('updates canvas size and widgets from loaded image dimensions', () => {
|
||||
const widthWidget = makeWidget('width', 512)
|
||||
const heightWidget = makeWidget('height', 512)
|
||||
mockWidgets.push(widthWidget, heightWidget)
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
const fakeEvent = {
|
||||
target: {
|
||||
naturalWidth: 1920,
|
||||
naturalHeight: 1080
|
||||
}
|
||||
} as unknown as Event
|
||||
|
||||
painter.handleInputImageLoad(fakeEvent)
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(1920)
|
||||
expect(painter.canvasHeight.value).toBe(1080)
|
||||
expect(widthWidget.value).toBe(1920)
|
||||
expect(heightWidget.value).toBe(1080)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor visibility', () => {
|
||||
it('sets cursorVisible to true on pointer enter', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.handlePointerEnter()
|
||||
expect(painter.cursorVisible.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sets cursorVisible to false on pointer leave', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.handlePointerEnter()
|
||||
painter.handlePointerLeave()
|
||||
expect(painter.cursorVisible.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayBrushSize', () => {
|
||||
it('scales brush size by canvas display ratio', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
// canvasDisplayWidth=512, canvasWidth=512 → ratio=1
|
||||
// hardness=1 → effectiveRadius = radius * 1.0
|
||||
// displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20
|
||||
expect(painter.displayBrushSize.value).toBe(20)
|
||||
})
|
||||
|
||||
it('increases for soft brush hardness', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.brushHardness.value = 0
|
||||
// hardness=0 → effectiveRadius = 10 * 1.5 = 15
|
||||
// displayBrushSize = 15 * 2 * 1 = 30
|
||||
expect(painter.displayBrushSize.value).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activeHardness (via displayBrushSize)', () => {
|
||||
it('returns 1 for eraser regardless of brushHardness', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.brushHardness.value = 0.3
|
||||
painter.tool.value = 'eraser'
|
||||
|
||||
// eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20
|
||||
expect(painter.displayBrushSize.value).toBe(20)
|
||||
})
|
||||
|
||||
it('uses brushHardness for brush tool', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.tool.value = 'brush'
|
||||
painter.brushHardness.value = 0.5
|
||||
// hardness=0.5 → scale=1.25 → 10*1.25*2 = 25
|
||||
expect(painter.displayBrushSize.value).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerWidgetSerialization', () => {
|
||||
it('attaches serializeValue to the mask widget on init', () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
mountPainter()
|
||||
|
||||
expect(maskWidget.serializeValue).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeValue', () => {
|
||||
it('returns empty string when canvas has no strokes', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
mountPainter()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('returns existing modelValue when not dirty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const { modelValue } = mountPainter()
|
||||
modelValue.value = 'painter/existing.png [temp]'
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
// isCanvasEmpty() is true (no strokes drawn), so returns ''
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreCanvas', () => {
|
||||
it('builds correct URL from modelValue on mount', () => {
|
||||
const { modelValue } = mountPainter()
|
||||
// Before mount, set the modelValue
|
||||
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
|
||||
// With empty modelValue, restoreCanvas exits early
|
||||
expect(modelValue.value).toBe('')
|
||||
})
|
||||
|
||||
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
mountPainter('test-node', 'painter/my-image.png [temp]')
|
||||
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('filename=my-image.png')
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('subfolder=painter')
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('type=temp')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClear', () => {
|
||||
it('does not throw when canvas element is null', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(() => painter.handleClear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerDown', () => {
|
||||
it('ignores non-primary button clicks', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
const mockSetPointerCapture = vi.fn()
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
button: 2
|
||||
})
|
||||
Object.defineProperty(event, 'target', {
|
||||
value: {
|
||||
setPointerCapture: mockSetPointerCapture
|
||||
}
|
||||
})
|
||||
|
||||
painter.handlePointerDown(event)
|
||||
|
||||
expect(mockSetPointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
it('ignores non-primary button releases', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
const mockReleasePointerCapture = vi.fn()
|
||||
const event = {
|
||||
button: 2,
|
||||
target: {
|
||||
releasePointerCapture: mockReleasePointerCapture
|
||||
}
|
||||
} as unknown as PointerEvent
|
||||
|
||||
painter.handlePointerUp(event)
|
||||
|
||||
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
verifyAssetSupportedCandidates,
|
||||
MODEL_FILE_EXTENSIONS
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
@@ -156,6 +158,134 @@ describe('scanNodeModelCandidates', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('enriches candidates with url/hash/directory from node.properties.models', () => {
|
||||
// Regression: bypass/un-bypass cycle previously lost url metadata
|
||||
// because realtime scan only reads widget values. Per-node embedded
|
||||
// metadata in `properties.models` persists across mode toggles, so
|
||||
// the scan now enriches candidates from that source.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
|
||||
'other_model.safetensors'
|
||||
])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/missing_model',
|
||||
directory: 'checkpoints',
|
||||
hash: 'abc123',
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBe('https://example.com/missing_model')
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
expect(result[0].hash).toBe('abc123')
|
||||
expect(result[0].hashType).toBe('sha256')
|
||||
})
|
||||
|
||||
it('preserves existing candidate fields when enriching (no overwrite)', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/new_url',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
// scanComboWidget already sets directory via getDirectory; enrichment
|
||||
// does not overwrite it.
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// url was not set by scan, so enrichment fills it in.
|
||||
expect(result[0].url).toBe('https://example.com/new_url')
|
||||
})
|
||||
|
||||
it('skips enrichment when candidate and embedded model directories differ', () => {
|
||||
// A node can list the same model name under multiple directories
|
||||
// (e.g. a LoRA present in both `loras` and `loras/subdir`). Name-only
|
||||
// matching would stamp the wrong url/hash onto the candidate, so
|
||||
// enrichment must agree on directory when the candidate already has
|
||||
// one.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'collision_model.safetensors', [])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collision_model.safetensors',
|
||||
url: 'https://example.com/wrong_dir_url',
|
||||
directory: 'wrong_dir'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// Directory mismatch — enrichment should not stamp the wrong url.
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not enrich candidates with mismatched model names', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'different_model.safetensors',
|
||||
url: 'https://example.com/different',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllModelCandidates', () => {
|
||||
@@ -925,6 +1055,86 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
|
||||
// Interior properties.models references the workflow-level model
|
||||
// but its widget value does not — forcing the workflow-level entry
|
||||
// down the unmatched path where isModelReferencedByActiveNode
|
||||
// decides. Previously the helper ignored the bypassed container.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
|
||||
// Positive control for the bypassed case above: identical fixture
|
||||
// with container mode=0 must still surface the unmatched workflow-
|
||||
// level model. Guards against a regression where the ancestor gate
|
||||
// drops every workflow-level entry regardless of context.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('rare_model.safetensors')
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when interior reference is under a different directory', async () => {
|
||||
// Same name, different directory: the interior's properties.models
|
||||
// entry is not the same asset as the workflow-level entry, so the
|
||||
// fallback helper must not treat it as a reference that keeps the
|
||||
// workflow-level model alive.
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
directory: 'loras'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: ['unrelated_widget.safetensors']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
url: 'https://example.com/collide',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
@@ -30,6 +32,39 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills url/hash/directory onto a candidate from the node's embedded
|
||||
* `properties.models` metadata when the names match. The full pipeline
|
||||
* does this via enrichWithEmbeddedMetadata + graphData.models, but the
|
||||
* realtime single-node scan (paste, un-bypass) otherwise loses these
|
||||
* fields — making the Missing Model row's download/copy-url buttons
|
||||
* disappear after a bypass/un-bypass cycle.
|
||||
*/
|
||||
function enrichCandidateFromNodeProperties(
|
||||
candidate: MissingModelCandidate,
|
||||
embeddedModels: readonly ModelFile[] | undefined
|
||||
): MissingModelCandidate {
|
||||
if (!embeddedModels?.length) return candidate
|
||||
// Require directory agreement when the candidate already has one —
|
||||
// a single node can reference two models with the same name under
|
||||
// different directories (e.g. a LoRA present in multiple folders);
|
||||
// name-only matching would stamp the wrong url/hash onto the
|
||||
// candidate. Mirrors the directory check in enrichWithEmbeddedMetadata.
|
||||
const match = embeddedModels.find(
|
||||
(m) =>
|
||||
m.name === candidate.name &&
|
||||
(!candidate.directory || candidate.directory === m.directory)
|
||||
)
|
||||
if (!match) return candidate
|
||||
return {
|
||||
...candidate,
|
||||
directory: candidate.directory ?? match.directory,
|
||||
url: candidate.url ?? match.url,
|
||||
hash: candidate.hash ?? match.hash,
|
||||
hashType: candidate.hashType ?? match.hash_type
|
||||
}
|
||||
}
|
||||
|
||||
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
@@ -107,6 +142,8 @@ export function scanNodeModelCandidates(
|
||||
if (!executionId) return []
|
||||
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const embeddedModels = (node as { properties?: { models?: ModelFile[] } })
|
||||
.properties?.models
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
|
||||
@@ -122,7 +159,11 @@ export function scanNodeModelCandidates(
|
||||
)
|
||||
}
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
if (candidate) {
|
||||
candidates.push(
|
||||
enrichCandidateFromNodeProperties(candidate, embeddedModels)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
@@ -231,9 +272,18 @@ export async function enrichWithEmbeddedMetadata(
|
||||
// model — not merely because any unrelated active node exists. A
|
||||
// reference is any widget value (or node.properties.models entry)
|
||||
// that matches the model name on an active node.
|
||||
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
||||
// otherwise rebuild it on every unmatched entry.
|
||||
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
||||
const activeUnmatched = unmatched.filter(
|
||||
(m) =>
|
||||
m.sourceNodeType !== '' || isModelReferencedByActiveNode(m.name, allNodes)
|
||||
m.sourceNodeType !== '' ||
|
||||
isModelReferencedByActiveNode(
|
||||
m.name,
|
||||
m.directory,
|
||||
allNodes,
|
||||
flattenedNodeById
|
||||
)
|
||||
)
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
@@ -276,7 +326,9 @@ export async function enrichWithEmbeddedMetadata(
|
||||
|
||||
function isModelReferencedByActiveNode(
|
||||
modelName: string,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>
|
||||
modelDirectory: string | undefined,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
@@ -284,12 +336,30 @@ function isModelReferencedByActiveNode(
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
||||
|
||||
// Require directory agreement when both sides specify one, so a
|
||||
// same-name entry under a different folder does not keep an
|
||||
// unrelated workflow-level model alive as missing.
|
||||
const embeddedModels = (
|
||||
node.properties as { models?: Array<{ name: string }> } | undefined
|
||||
node.properties as
|
||||
| { models?: Array<{ name: string; directory?: string }> }
|
||||
| undefined
|
||||
)?.models
|
||||
if (embeddedModels?.some((m) => m.name === modelName)) return true
|
||||
if (
|
||||
embeddedModels?.some(
|
||||
(m) =>
|
||||
m.name === modelName &&
|
||||
(modelDirectory === undefined ||
|
||||
m.directory === undefined ||
|
||||
m.directory === modelDirectory)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// widgets_values carries only the name, so directory cannot be
|
||||
// checked here — fall back to filename matching.
|
||||
const values = node.widgets_values
|
||||
if (!values) continue
|
||||
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
||||
@@ -300,6 +370,22 @@ function isModelReferencedByActiveNode(
|
||||
return false
|
||||
}
|
||||
|
||||
function isAncestorPathActiveInFlattened(
|
||||
executionId: string,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const ancestorId of getParentExecutionIds(executionId)) {
|
||||
const ancestor = nodeById.get(ancestorId)
|
||||
if (!ancestor) continue
|
||||
if (
|
||||
ancestor.mode === LGraphEventMode.NEVER ||
|
||||
ancestor.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: ComfyWorkflowJSON
|
||||
|
||||
@@ -39,7 +39,7 @@ const existingOutput = computed(() => {
|
||||
<div
|
||||
v-else-if="hasOutputs"
|
||||
role="article"
|
||||
data-testid="arrange-preview"
|
||||
data-testid="linear-arrange-preview"
|
||||
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
|
||||
>
|
||||
<div
|
||||
@@ -54,7 +54,7 @@ const existingOutput = computed(() => {
|
||||
<div
|
||||
v-else
|
||||
role="article"
|
||||
data-testid="arrange-no-outputs"
|
||||
data-testid="linear-arrange-no-outputs"
|
||||
class="mx-auto flex h-full w-lg flex-col items-center justify-center gap-6 p-8 text-center"
|
||||
>
|
||||
<p class="m-0 text-base-foreground">
|
||||
@@ -75,7 +75,12 @@ const existingOutput = computed(() => {
|
||||
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
data-testid="linear-arrange-switch-to-outputs"
|
||||
@click="setMode('builder:outputs')"
|
||||
>
|
||||
{{ t('linearMode.arrange.switchToOutputsButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,8 @@ import {
|
||||
collectAllNodes,
|
||||
forEachNode,
|
||||
getNodeByExecutionId,
|
||||
isAncestorPathActive,
|
||||
isMissingCandidateActive,
|
||||
triggerCallbackOnAllNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
@@ -1436,10 +1438,21 @@ export class ComfyApp {
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
// Drop missing-node entries whose enclosing subgraph is
|
||||
// muted/bypassed. The initial JSON scan only checks each node's
|
||||
// own mode; the cascade from an inactive container is applied here
|
||||
// using the now-configured live graph.
|
||||
const activeMissingNodeTypes = missingNodeTypes.filter(
|
||||
(n) =>
|
||||
typeof n === 'string' ||
|
||||
n.nodeId == null ||
|
||||
isAncestorPathActive(this.rootGraph, String(n.nodeId))
|
||||
)
|
||||
|
||||
if (!skipAssetScans) {
|
||||
await this.runMissingModelPipeline(
|
||||
graphData,
|
||||
missingNodeTypes,
|
||||
activeMissingNodeTypes,
|
||||
silentAssetErrors
|
||||
)
|
||||
|
||||
@@ -1482,7 +1495,7 @@ export class ComfyApp {
|
||||
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
const enrichedCandidates = await enrichWithEmbeddedMetadata(
|
||||
const enrichedAll = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
async (name, directory) => {
|
||||
@@ -1498,6 +1511,19 @@ export class ComfyApp {
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
|
||||
// scans only checked each node's own mode; the cascade from an
|
||||
// inactive container to its interior happens here.
|
||||
// Asymmetric on purpose: a candidate dropped here is not resurrected if
|
||||
// the user un-bypasses the container mid-verification. The realtime
|
||||
// mode-change path (handleNodeModeChange → scanAndAddNodeErrors) is
|
||||
// responsible for surfacing errors after an un-bypass.
|
||||
const enrichedCandidates = enrichedAll.filter(
|
||||
(c) =>
|
||||
c.nodeId == null ||
|
||||
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
||||
)
|
||||
|
||||
const missingModels: ModelFile[] = enrichedCandidates
|
||||
.filter((c) => c.isMissing === true && c.url)
|
||||
.map((c) => ({
|
||||
@@ -1535,8 +1561,10 @@ export class ComfyApp {
|
||||
)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = enrichedCandidates.filter(
|
||||
(c) => c.isMissing === true
|
||||
// Re-check ancestor: user may have bypassed a container
|
||||
// while verification was in flight.
|
||||
const confirmed = enrichedCandidates.filter((c) =>
|
||||
isMissingCandidateActive(this.rootGraph, c)
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed, {
|
||||
@@ -1643,7 +1671,11 @@ export class ComfyApp {
|
||||
): Promise<void> {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed.
|
||||
const candidates = allCandidates.filter((c) =>
|
||||
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
||||
)
|
||||
|
||||
if (!candidates.length) {
|
||||
this.cacheMediaCandidates(activeWf, [])
|
||||
@@ -1655,7 +1687,10 @@ export class ComfyApp {
|
||||
void verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
// Re-check ancestor after async verification (see model pipeline).
|
||||
const confirmed = candidates.filter((c) =>
|
||||
isMissingCandidateActive(this.rootGraph, c)
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user