Compare commits

..

5 Commits

Author SHA1 Message Date
bymyself
55bec130f6 test: improve test robustness and add negative case
- Replace fragile beforeAll + dynamic import with static import + vi.hoisted()
- Extract helpers (getExtension, callBeforeRegister, getUpload) to remove
  repeated non-null assertions and type casts
- Add negative test for unknown node types (UnknownNode)
- Use Partial<ComfyNodeDef> with targeted cast instead of as never

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11356#pullrequestreview-2919342692
2026-04-20 02:51:37 -07:00
bymyself
a2b6e0e7df fix: remove redundant optional chaining and type cast
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11356#pullrequestreview-2919342692
2026-04-20 02:47:34 -07:00
dante01yoon
144e60b4ae fix: scope IMAGEUPLOAD fallback to image+video and guard existing upload
Address review feedback on the cloud LoadImage paste fallback:

- Drop LoadAudio (and LoadImageMask) from the fallback. LoadAudio has its
  own AUDIOUPLOAD widget via Comfy.UploadAudio; routing audio through
  IMAGEUPLOAD would reject every pasted/dropped audio file.
- Synthesize video_upload: true when falling back for LoadVideo so
  useImageUploadWidget runs in video mode (correct accept filter and
  preview), instead of silently filtering out video files.
- Bail out early when required.upload is already set so we never clobber
  a sibling uploader (Comfy.UploadAudio runs first via core/index import
  order).
- Tests: cover LoadVideo (video_upload synthesized), LoadAudio (no
  IMAGEUPLOAD attached), and an existing upload widget being preserved.
2026-04-18 17:20:47 +09:00
dante01yoon
027233c3d6 fix: attach IMAGEUPLOAD widget to known media loader nodes
Cloud's backend may serve LoadImage / LoadVideo / LoadAudio without the
image_upload / video_upload / animated_image_upload flag on their media
input. Without that flag, Comfy.UploadImage skips wiring the IMAGEUPLOAD
widget, so node.pasteFiles is never set and the right-click 'Paste Image'
context menu item never appears.

Add a node-name fallback that attaches IMAGEUPLOAD when the input is a
combo on a known media loader node, restoring paste/drag-drop on cloud
LoadImage.
2026-04-18 16:03:32 +09:00
dante01yoon
82c6398972 test: add failing test for missing IMAGEUPLOAD on cloud LoadImage 2026-04-18 16:00:40 +09:00
23 changed files with 520 additions and 808 deletions

View File

@@ -98,50 +98,3 @@ jobs:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -131,38 +131,6 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
expect(switched).toBe(true)
})
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
try {
await comfyPage.settings.setSetting(settingId, !initialValue)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.LiteGraph!.middle_click_slot_add_default_node
)
)
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {

View File

@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
)
test(
'Empty state matches the screenshot baseline',
'Empty state matches screenshot baseline',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -102,6 +102,7 @@
"fuse.js": "^7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",

20
pnpm-lock.yaml generated
View File

@@ -267,6 +267,9 @@ catalogs:
jsonata:
specifier: ^2.1.0
version: 2.1.0
jsondiffpatch:
specifier: ^0.7.3
version: 0.7.3
knip:
specifier: ^6.3.1
version: 6.3.1
@@ -554,6 +557,9 @@ importers:
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch:
specifier: 'catalog:'
version: 0.7.3
loglevel:
specifier: ^1.9.2
version: 1.9.2
@@ -1774,6 +1780,9 @@ packages:
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
'@dmsnell/diff-match-patch@1.1.0':
resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
@@ -7260,6 +7269,11 @@ packages:
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsondiffpatch@0.7.3:
resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -11225,6 +11239,8 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@dmsnell/diff-match-patch@1.1.0': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@emmetio/abbreviation@2.3.3':
@@ -17124,6 +17140,10 @@ snapshots:
jsonc-parser@3.3.1: {}
jsondiffpatch@0.7.3:
dependencies:
'@dmsnell/diff-match-patch': 1.1.0
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1

View File

@@ -90,6 +90,7 @@ catalog:
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.3.1
lenis: ^1.3.21
lint-staged: ^16.2.7

View File

@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs'
const TARGET = 80
const MILESTONE_STEP = 5
const MIN_DELTA = 0.05
const BAR_WIDTH = 20
interface CoverageData {
@@ -72,9 +71,8 @@ function formatPct(value: number): string {
}
function formatDelta(delta: number): string {
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
const sign = rounded >= 0 ? '+' : ''
return sign + rounded.toFixed(1) + '%'
const sign = delta >= 0 ? '+' : ''
return sign + delta.toFixed(1) + '%'
}
function crossedMilestone(prev: number, curr: number): number | null {
@@ -152,18 +150,15 @@ function main() {
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
const unitDelta =
unitCurrent !== null && unitBaseline !== null
? unitCurrent.percentage - unitBaseline.percentage
: 0
const unitImproved =
unitCurrent !== null &&
unitBaseline !== null &&
unitCurrent.percentage > unitBaseline.percentage
const e2eDelta =
e2eCurrent !== null && e2eBaseline !== null
? e2eCurrent.percentage - e2eBaseline.percentage
: 0
const unitImproved = unitDelta >= MIN_DELTA
const e2eImproved = e2eDelta >= MIN_DELTA
const e2eImproved =
e2eCurrent !== null &&
e2eBaseline !== null &&
e2eCurrent.percentage > e2eBaseline.percentage
if (!unitImproved && !e2eImproved) {
process.exit(0)
@@ -177,12 +172,12 @@ function main() {
)
summaryLines.push('')
if (unitImproved) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
if (unitCurrent && unitBaseline) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
}
if (e2eImproved) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
if (e2eCurrent && e2eBaseline) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
}
summaryLines.push('')

View File

@@ -1,268 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import WidgetImageCrop from './WidgetImageCrop.vue'
const resizeObserverCallbacks: Array<() => void> = []
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...(actual as Record<string, unknown>),
useResizeObserver: (_target: unknown, cb: () => void) => {
resizeObserverCallbacks.push(cb)
return { stop: vi.fn() }
}
}
})
const mockResolveNode = vi.hoisted(() =>
vi.fn<(id: NodeId) => LGraphNode | null>()
)
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (id: NodeId) => mockResolveNode(id)
}))
const mockGetNodeImageUrls = vi.hoisted(() =>
vi.fn<(node: LGraphNode) => string[] | null | undefined>()
)
type MockOutputStore = {
nodeOutputs: Record<string, unknown>
nodePreviewImages: Record<string, unknown>
getNodeImageUrls: typeof mockGetNodeImageUrls
}
const useNodeOutputStoreMock = vi.hoisted(() => vi.fn<() => MockOutputStore>())
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => useNodeOutputStoreMock()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: { id: 'test-graph' }
}
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getNodeWidgets: vi.fn(() => [])
})
}))
async function flushTicks() {
await Promise.resolve()
await nextTick()
}
describe('WidgetImageCrop', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
}
}
}
})
beforeEach(() => {
resizeObserverCallbacks.length = 0
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
nodePreviewImages: reactive<Record<string, unknown>>({}),
getNodeImageUrls: mockGetNodeImageUrls
}
useNodeOutputStoreMock.mockReturnValue(outputStore)
const source = createMockLGraphNode({ id: 99, isSubgraphNode: () => false })
const crop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => source),
getInputLink: vi.fn(),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(crop)
mockGetNodeImageUrls.mockImplementation((n) =>
n === source ? ['https://example.com/a.png'] : null
)
setActivePinia(createTestingPinia({ stubActions: true }))
})
it('renders empty state copy when no image URL is available', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('No input image connected')).toBeTruthy()
unmount()
attach.remove()
})
it('shows crop overlay after the preview image loads', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
attach.remove()
})
it('toggles aspect ratio lock from the toolbar button', async () => {
const user = userEvent.setup()
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
await flushTicks()
expect(
screen.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeTruthy()
unmount()
attach.remove()
})
it('renders ratio controls when the widget is enabled', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('Ratio')).toBeTruthy()
unmount()
attach.remove()
})
})

View File

@@ -1,86 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Button from './Button.vue'
describe('Button', () => {
it('renders slot content inside a button by default', () => {
render(Button, {
slots: { default: 'Click me' }
})
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('fires click events when enabled', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
slots: { default: 'Click me' },
attrs: { onClick }
})
await user.click(screen.getByRole('button', { name: 'Click me' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('hides slot content, shows a spinner, and disables the button while loading', () => {
const { container } = render(Button, {
props: { loading: true },
slots: { default: 'Submit' }
})
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role
expect(container.querySelector('.pi-spin')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
})
it('does not fire click when loading', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
props: { loading: true },
attrs: { onClick }
})
await user.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('disables the button when disabled prop is true', () => {
render(Button, {
props: { disabled: true },
slots: { default: 'Nope' }
})
expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled()
})
it('renders as an anchor when as="a"', () => {
const { container } = render(Button, {
props: { as: 'a' },
slots: { default: 'Link' }
})
// eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test
const root = container.firstElementChild
expect(root?.tagName).toBe('A')
})
it('applies variant classes through buttonVariants', () => {
render(Button, {
props: { variant: 'primary' },
slots: { default: 'Primary' }
})
expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass(
'bg-primary-background'
)
})
})

View File

@@ -1,141 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import Slider from './Slider.vue'
async function flush() {
await nextTick()
await nextTick()
}
describe('Slider', () => {
it('renders a single thumb with role="slider" for a single-value model', async () => {
render(Slider, { props: { modelValue: [50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(1)
})
it('renders one thumb per value for a range model', async () => {
render(Slider, { props: { modelValue: [20, 50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(2)
})
it('exposes min/max/step via ARIA on the thumb', async () => {
render(Slider, {
props: { modelValue: [10], min: 0, max: 200, step: 5 }
})
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '200')
expect(thumb).toHaveAttribute('aria-valuenow', '10')
})
it('emits update:modelValue with an increased value on ArrowRight', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeGreaterThan(50)
})
it('emits update:modelValue with a decreased value on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeLessThan(50)
})
it('respects step size when emitting updates', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 10,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith([60])
})
it('marks the root as disabled when disabled prop is set', async () => {
const { container } = render(Slider, {
props: { modelValue: [30], disabled: true }
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root
const root = container.querySelector('[data-slot="slider"]')
expect(root).toHaveAttribute('data-disabled')
})
it('does not emit updates via keyboard when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
disabled: true,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
})
})

View File

@@ -1,71 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Textarea from './Textarea.vue'
describe('Textarea', () => {
it('renders a textarea element', () => {
render(Textarea)
expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement)
})
it('populates the textarea with the initial v-model value', () => {
render(Textarea, { props: { modelValue: 'initial text' } })
expect(screen.getByRole('textbox')).toHaveValue('initial text')
})
it('emits update:modelValue as the user types', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: string | number | undefined) => void>()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
}
})
await user.type(screen.getByRole('textbox'), 'hi')
expect(onUpdate).toHaveBeenCalled()
expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi')
})
it('forwards placeholder and rows attrs to the native textarea', () => {
render(Textarea, {
attrs: { placeholder: 'Write something', rows: 6 }
})
const textarea = screen.getByPlaceholderText('Write something')
expect(textarea).toHaveAttribute('rows', '6')
})
it('does not accept typed input when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
attrs: { disabled: true }
})
const textarea = screen.getByRole('textbox')
expect(textarea).toBeDisabled()
await user.type(textarea, 'blocked')
expect(onUpdate).not.toHaveBeenCalled()
expect(textarea).toHaveValue('')
})
it('forwards custom class alongside internal classes', () => {
render(Textarea, { props: { class: 'custom-extra-class' } })
expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class')
})
})

View File

@@ -1,12 +1,18 @@
/* eslint-disable vue/one-component-per-file */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WidgetImageCrop from '@/components/imagecrop/WidgetImageCrop.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
createMockLGraphNode,
createMockSubgraphNode
@@ -158,10 +164,10 @@ type CropVm = Record<string, unknown> & {
function setupImageLayout(vm: CropVm, nw: number, nh: number) {
/* Harness root + image are not RTL queries — layout is driven by composable state */
/* eslint-disable testing-library/no-node-access */
const container = vm.$el as HTMLDivElement
const img = container.querySelector('img')
/* eslint-enable testing-library/no-node-access */
mountContainerLayout(container, 400, 300)
if (img) {
Object.defineProperty(img, 'naturalWidth', {
@@ -368,10 +374,10 @@ describe('useImageCrop', () => {
it('uses scale factor 1 when natural dimensions are zero', async () => {
const vm = await mountHarness()
/* eslint-disable testing-library/no-node-access */
const container = vm.$el as HTMLDivElement
const img = container.querySelector('img')
/* eslint-enable testing-library/no-node-access */
if (!img) throw new Error('expected preview img')
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 0 })
Object.defineProperty(img, 'naturalHeight', {
@@ -574,3 +580,199 @@ describe('useImageCrop', () => {
expect(vm.cropHeight as number).toBeGreaterThan(h0)
})
})
describe('WidgetImageCrop', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
}
}
}
})
beforeEach(() => {
resizeObserverCallbacks.length = 0
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
nodePreviewImages: reactive<Record<string, unknown>>({}),
getNodeImageUrls: mockGetNodeImageUrls
}
useNodeOutputStoreMock.mockReturnValue(outputStore)
const source = createMockLGraphNode({ id: 99, isSubgraphNode: () => false })
const crop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => source),
getInputLink: vi.fn(),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(crop)
mockGetNodeImageUrls.mockImplementation((n) =>
n === source ? ['https://example.com/a.png'] : null
)
setActivePinia(createTestingPinia({ stubActions: true }))
})
it('renders empty state copy when no image URL is available', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('No input image connected')).toBeTruthy()
unmount()
attach.remove()
})
it('shows crop overlay after the preview image loads', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
attach.remove()
})
it('toggles aspect ratio lock from the toolbar button', async () => {
const user = userEvent.setup()
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
await flushTicks()
expect(
screen.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeTruthy()
unmount()
attach.remove()
})
it('renders ratio controls when the widget is enabled', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('Ratio')).toBeTruthy()
unmount()
attach.remove()
})
})

View File

@@ -18,6 +18,7 @@ app.registerExtension({
suggestionsNumber: null,
init(this: SlotDefaultsExtension) {
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
id: 'Comfy.NodeSuggestions.number',
category: ['Comfy', 'Node Search Box', 'NodeSuggestions'],

View File

@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
interface CapturedExtension {
name: string
beforeRegisterNodeDef?: ComfyExtension['beforeRegisterNodeDef']
}
const registerExtension = vi.hoisted(() =>
vi.fn<(ext: CapturedExtension) => void>()
)
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: (ext: CapturedExtension) => registerExtension(ext)
}
}))
// Static import — vi.mock hoisting ensures the mock is ready before the
// module's top-level app.registerExtension() call executes.
import './uploadImage'
function getExtension(): CapturedExtension {
const ext = registerExtension.mock.calls
.map(([e]) => e)
.find((e) => e.name === 'Comfy.UploadImage')
if (!ext) throw new Error('Comfy.UploadImage not registered')
return ext
}
function callBeforeRegister(nodeData: {
name: string
input: { required: Record<string, unknown> }
}) {
getExtension().beforeRegisterNodeDef!(
undefined as unknown as typeof LGraphNode,
nodeData as ComfyNodeDef,
undefined as never
)
}
function getUpload(
required: Record<string, unknown>
): [string, Record<string, unknown>] | undefined {
return required.upload as [string, Record<string, unknown>] | undefined
}
describe('Comfy.UploadImage extension', () => {
it('attaches an IMAGEUPLOAD widget when the image input declares image_upload', () => {
const nodeData = {
name: 'LoadImage',
input: {
required: {
image: ['COMBO', { image_upload: true }]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('image')
})
it('attaches an IMAGEUPLOAD widget for LoadImage even when the backend omits image_upload', () => {
const nodeData = {
name: 'LoadImage',
input: {
required: {
image: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('image')
expect(upload![1].image_upload).toBe(true)
})
it('attaches an IMAGEUPLOAD widget for LoadVideo with video_upload synthesized', () => {
const nodeData = {
name: 'LoadVideo',
input: {
required: {
file: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('file')
expect(upload![1].video_upload).toBe(true)
})
it('does not attach an upload widget for unknown node types', () => {
const nodeData = {
name: 'UnknownNode',
input: {
required: {
image: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
expect(
getUpload(nodeData.input.required as Record<string, unknown>)
).toBeUndefined()
})
it('does not touch LoadAudio — audio is handled by Comfy.UploadAudio', () => {
const nodeData = {
name: 'LoadAudio',
input: {
required: {
audio: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
expect(
getUpload(nodeData.input.required as Record<string, unknown>)
).toBeUndefined()
})
it('never overwrites an upload widget another extension already attached', () => {
const existingUpload = ['AUDIOUPLOAD', {}]
const nodeData = {
name: 'LoadAudio',
input: {
required: {
audio: ['COMBO', {}],
upload: existingUpload
}
}
}
callBeforeRegister(nodeData)
expect((nodeData.input.required as Record<string, unknown>).upload).toBe(
existingUpload
)
})
})

View File

@@ -2,6 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
type ComfyNodeDef,
type InputSpec,
isComboInputSpec,
isMediaUploadComboInput
} from '@/schemas/nodeDefSchema'
@@ -9,13 +10,31 @@ import { app } from '../../scripts/app'
// Adds an upload button to the nodes
// Cloud's backend may serve these loader nodes without the image_upload /
// video_upload flag on their media input. Without a fallback the IMAGEUPLOAD
// widget is never attached, so node.pasteFiles stays unset and the right-click
// "Paste Image" menu item never appears on cloud LoadImage nodes.
//
// LoadAudio is intentionally excluded — audio uses a separate AUDIOUPLOAD
// widget owned by Comfy.UploadAudio. Routing audio through IMAGEUPLOAD would
// reject every audio file the user pasted or dropped.
const FALLBACK_MEDIA_LOADER_INPUTS: Record<
string,
{ inputName: string; flag: 'image_upload' | 'video_upload' }
> = {
LoadImage: { inputName: 'image', flag: 'image_upload' },
LoadVideo: { inputName: 'file', flag: 'video_upload' }
}
const createUploadInput = (
imageInputName: string,
imageInputOptions: InputSpec
imageInputOptions: InputSpec,
extraOptions: Record<string, unknown> = {}
): InputSpec => [
'IMAGEUPLOAD',
{
...imageInputOptions[1],
...extraOptions,
imageInputName
}
]
@@ -26,6 +45,8 @@ app.registerExtension({
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return
// Don't clobber a sibling uploader (e.g. Comfy.UploadAudio's AUDIOUPLOAD).
if (required.upload) return
const found = Object.entries(required).find(([_, input]) =>
isMediaUploadComboInput(input)
@@ -35,6 +56,17 @@ app.registerExtension({
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)
return
}
const fallback = FALLBACK_MEDIA_LOADER_INPUTS[nodeData.name]
if (!fallback) return
const fallbackSpec = required[fallback.inputName]
if (!fallbackSpec || !isComboInputSpec(fallbackSpec)) return
// Synthesize the missing media-type flag so useImageUploadWidget picks the
// right accept filter (image/* vs video/*) for the loader's media kind.
required.upload = createUploadInput(fallback.inputName, fallbackSpec, {
[fallback.flag]: true
})
}
})

View File

@@ -484,56 +484,4 @@ describe('useMediaAssetActions', () => {
)
})
})
describe('deleteAssets - confirmation dialog item names', () => {
beforeEach(() => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockShowDialog.mockReset()
})
it('should show user_metadata display names instead of hash filenames', () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: 'asset-1',
name: 'c885097ab185ced82f017bcbc98948918499f7480315fd5b928b5bb8d4951efc.png',
user_metadata: { name: 'My Sunset Render' }
}),
createMockAsset({
id: 'asset-2',
name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2.png',
display_name: 'Portrait Variation'
})
]
void actions.deleteAssets(assets)
expect(mockShowDialog).toHaveBeenCalledTimes(1)
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
itemList: string[]
}
expect(dialogProps.itemList).toEqual([
'My Sunset Render',
'Portrait Variation'
])
})
it('should fall back to asset.name when no display name is available', () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'asset-3',
name: 'fallback-image.png'
})
void actions.deleteAssets(asset)
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
itemList: string[]
}
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
})

View File

@@ -595,7 +595,7 @@ export function useMediaAssetActions() {
count: assetArray.length
}),
type: 'delete',
itemList: assetArray.map((asset) => getAssetDisplayName(asset)),
itemList: assetArray.map((asset) => asset.name),
onConfirm: async () => {
// Show loading overlay for all assets being deleted
assetArray.forEach((asset) =>

View File

@@ -14,7 +14,7 @@
<button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
type ViewMode = 'gallery' | 'grid'

View File

@@ -19,7 +19,12 @@
v-if="activeItem"
:src="getItemSrc(activeItem)"
:alt="getItemAlt(activeItem, activeIndex)"
class="h-auto w-full rounded-sm object-contain"
:class="
cn(
'h-auto w-full rounded-sm object-contain transition-opacity',
showControls && 'opacity-50'
)
"
@load="handleImageLoad"
/>
@@ -233,7 +238,7 @@ const showNavButtons = computed(
)
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
const toggleButtonClass = actionButtonClass

View File

@@ -23,6 +23,10 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function captureWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
function updateSelectedItems(selectedItems: Set<string>) {
const id =
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
@@ -32,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
captureWorkflowState()
}
async function uploadFile(
@@ -105,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
widget.callback(uploadedPaths[0])
}
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
captureWorkflowState()
}
)

View File

@@ -1,9 +1,9 @@
import * as Sentry from '@sentry/vue'
import _ from 'es-toolkit/compat'
import * as jsondiffpatch from 'jsondiffpatch'
import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isDesktop } from '@/platform/distribution/types'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -20,37 +20,14 @@ function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
const logger = log.getLogger('ChangeTracker')
// Change to debug for more verbose logging
logger.setLevel('info')
function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
const reportedInactiveCalls = new Set<string>()
/**
* Report a ChangeTracker method being called on an inactive tracker —
* a lifecycle violation that usually indicates stale extension state or
* an incorrect call ordering. Reports once per method per workflow per
* session so the signal is not drowned out by hot-path invocations while
* still distinguishing between workflows.
*/
function reportInactiveTrackerCall(method: string, workflowPath: string) {
const key = `${method}:${workflowPath}`
if (reportedInactiveCalls.has(key)) return
reportedInactiveCalls.add(key)
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
if (isDesktop) {
Sentry.captureMessage(
`ChangeTracker.${method}() called on inactive tracker`,
{
level: 'warning',
tags: { workflow: workflowPath }
}
)
}
}
export class ChangeTracker {
static MAX_HISTORY = 50
/**
@@ -100,6 +77,7 @@ export class ChangeTracker {
// Do not reset the state if we are restoring.
if (this._restoringState) return
logger.debug('Reset State')
if (state) this.activeState = clone(state)
this.initialState = clone(this.activeState)
}
@@ -129,7 +107,10 @@ export class ChangeTracker {
*/
deactivate() {
if (!isActiveTracker(this)) {
reportInactiveTrackerCall('deactivate', this.workflow.path)
logger.warn(
'deactivate() called on inactive tracker for:',
this.workflow.path
)
return
}
if (!this._restoringState) this.captureCanvasState()
@@ -184,6 +165,13 @@ export class ChangeTracker {
this.initialState,
this.activeState
)
if (logger.getLevel() <= logger.levels.DEBUG && workflow.isModified) {
const diff = ChangeTracker.graphDiff(
this.initialState,
this.activeState
)
logger.debug('Graph diff:', diff)
}
}
}
@@ -193,18 +181,19 @@ export class ChangeTracker {
* Calling this on an inactive tracker would capture the wrong graph.
*/
captureCanvasState() {
const isUndoRedoing = this._restoringState
const isInsideChangeTransaction = this.changeCount > 0
if (
!app.graph ||
isInsideChangeTransaction ||
isUndoRedoing ||
this.changeCount ||
this._restoringState ||
ChangeTracker.isLoadingGraph
)
return
if (!isActiveTracker(this)) {
reportInactiveTrackerCall('captureCanvasState', this.workflow.path)
logger.warn(
'captureCanvasState called on inactive tracker for:',
this.workflow.path
)
return
}
@@ -218,6 +207,7 @@ export class ChangeTracker {
if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) {
this.undoQueue.shift()
}
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
this.activeState = currentState
this.redoQueue.length = 0
@@ -229,7 +219,7 @@ export class ChangeTracker {
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
console.warn(
logger.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
@@ -258,10 +248,22 @@ export class ChangeTracker {
async undo() {
await this.updateState(this.undoQueue, this.redoQueue)
logger.debug(
'Undo. Undo queue length:',
this.undoQueue.length,
'Redo queue length:',
this.redoQueue.length
)
}
async redo() {
await this.updateState(this.redoQueue, this.undoQueue)
logger.debug(
'Redo. Undo queue length:',
this.undoQueue.length,
'Redo queue length:',
this.redoQueue.length
)
}
async undoRedo(e: KeyboardEvent) {
@@ -335,6 +337,7 @@ export class ChangeTracker {
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('captureCanvasState on keydown')
changeTracker.captureCanvasState()
})
},
@@ -344,21 +347,25 @@ export class ChangeTracker {
window.addEventListener('keyup', () => {
if (keyIgnored) {
keyIgnored = false
logger.debug('captureCanvasState on keyup')
captureState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
logger.debug('captureCanvasState on mouseup')
captureState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
logger.debug('captureCanvasState on promptQueued')
captureState()
})
api.addEventListener('graphCleared', () => {
logger.debug('captureCanvasState on graphCleared')
captureState()
})
@@ -366,6 +373,7 @@ export class ChangeTracker {
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
logger.debug('captureCanvasState on processMouseUp')
captureState()
return v
}
@@ -382,6 +390,7 @@ export class ChangeTracker {
callback(v)
captureState()
}
logger.debug('captureCanvasState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -389,6 +398,7 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
logger.debug('captureCanvasState on contextMenuClose')
captureState()
return v
}
@@ -491,4 +501,25 @@ export class ChangeTracker {
return false
}
private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes,
groups: graph.groups,
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id
}
return 0
})
}
}
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
}
}

View File

@@ -1,43 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
ComfyApp: class {}
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
describe('useLitegraphService().getCanvasCenter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
})
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([110, 70])
})
})