mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-17 20:09:35 +00:00
Compare commits
10 Commits
fix/load-i
...
batch-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5868891a3d | ||
|
|
4c7729ee0b | ||
|
|
40083d593b | ||
|
|
7089a7d1a0 | ||
|
|
3b4811b00d | ||
|
|
b756545f59 | ||
|
|
da91bdc957 | ||
|
|
cf3006f82c | ||
|
|
be2d757c47 | ||
|
|
54f3127658 |
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -98,3 +98,50 @@ 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
|
||||
|
||||
@@ -131,6 +131,38 @@ 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
|
||||
}) => {
|
||||
|
||||
@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
'Empty state matches the 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 |
@@ -102,7 +102,6 @@
|
||||
"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
20
pnpm-lock.yaml
generated
@@ -267,9 +267,6 @@ 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
|
||||
@@ -557,9 +554,6 @@ importers:
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.3
|
||||
loglevel:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
@@ -1780,9 +1774,6 @@ 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==}
|
||||
|
||||
@@ -7269,11 +7260,6 @@ 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==}
|
||||
|
||||
@@ -11239,8 +11225,6 @@ 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':
|
||||
@@ -17140,10 +17124,6 @@ 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
|
||||
|
||||
@@ -90,7 +90,6 @@ 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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
@@ -71,8 +72,9 @@ function formatPct(value: number): string {
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return sign + delta.toFixed(1) + '%'
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
@@ -150,15 +152,18 @@ function main() {
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitImproved =
|
||||
unitCurrent !== null &&
|
||||
unitBaseline !== null &&
|
||||
unitCurrent.percentage > unitBaseline.percentage
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eImproved =
|
||||
e2eCurrent !== null &&
|
||||
e2eBaseline !== null &&
|
||||
e2eCurrent.percentage > e2eBaseline.percentage
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
@@ -172,12 +177,12 @@ function main() {
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
268
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
268
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
86
src/components/ui/button/Button.test.ts
Normal file
86
src/components/ui/button/Button.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
141
src/components/ui/slider/Slider.test.ts
Normal file
141
src/components/ui/slider/Slider.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
71
src/components/ui/textarea/Textarea.test.ts
Normal file
71
src/components/ui/textarea/Textarea.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,12 @@
|
||||
/* 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
|
||||
@@ -164,10 +158,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', {
|
||||
@@ -374,10 +368,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', {
|
||||
@@ -580,199 +574,3 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ 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'],
|
||||
|
||||
@@ -484,4 +484,56 @@ 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,7 +595,7 @@ export function useMediaAssetActions() {
|
||||
count: assetArray.length
|
||||
}),
|
||||
type: 'delete',
|
||||
itemList: assetArray.map((asset) => asset.name),
|
||||
itemList: assetArray.map((asset) => getAssetDisplayName(asset)),
|
||||
onConfirm: async () => {
|
||||
// Show loading overlay for all assets being deleted
|
||||
assetArray.forEach((asset) =>
|
||||
|
||||
@@ -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 transition-opacity hover:opacity-80 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 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 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 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'
|
||||
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
class="h-auto w-full rounded-sm object-contain"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
@@ -238,7 +233,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-md 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-interface transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ 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
|
||||
@@ -36,7 +32,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +105,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,14 +20,37 @@ 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
|
||||
/**
|
||||
@@ -77,7 +100,6 @@ 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)
|
||||
}
|
||||
@@ -107,10 +129,7 @@ export class ChangeTracker {
|
||||
*/
|
||||
deactivate() {
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('deactivate', this.workflow.path)
|
||||
return
|
||||
}
|
||||
if (!this._restoringState) this.captureCanvasState()
|
||||
@@ -165,13 +184,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,19 +193,18 @@ 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 ||
|
||||
this.changeCount ||
|
||||
this._restoringState ||
|
||||
isInsideChangeTransaction ||
|
||||
isUndoRedoing ||
|
||||
ChangeTracker.isLoadingGraph
|
||||
)
|
||||
return
|
||||
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('captureCanvasState', this.workflow.path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,7 +218,6 @@ 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
|
||||
@@ -219,7 +229,7 @@ export class ChangeTracker {
|
||||
checkState() {
|
||||
if (!ChangeTracker._checkStateWarned) {
|
||||
ChangeTracker._checkStateWarned = true
|
||||
logger.warn(
|
||||
console.warn(
|
||||
'checkState() is deprecated — use captureCanvasState() instead.'
|
||||
)
|
||||
}
|
||||
@@ -248,22 +258,10 @@ 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) {
|
||||
@@ -337,7 +335,6 @@ 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()
|
||||
})
|
||||
},
|
||||
@@ -347,25 +344,21 @@ 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()
|
||||
})
|
||||
|
||||
@@ -373,7 +366,6 @@ 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
|
||||
}
|
||||
@@ -390,7 +382,6 @@ export class ChangeTracker {
|
||||
callback(v)
|
||||
captureState()
|
||||
}
|
||||
logger.debug('captureCanvasState on prompt')
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
|
||||
@@ -398,7 +389,6 @@ 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
|
||||
}
|
||||
@@ -501,25 +491,4 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
43
src/services/litegraphService.test.ts
Normal file
43
src/services/litegraphService.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user