mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
3 Commits
test/cov-l
...
glary/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cd604961 | ||
|
|
84ea0d3b14 | ||
|
|
b8b8a6056d |
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [230, 110],
|
||||
"size": [270, 317.5666809082031],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [-80.55032348632812, 375.2260443115233],
|
||||
"size": [270, 80.23332977294922],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveFloat"
|
||||
},
|
||||
"widgets_values": [0]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8264462809917354,
|
||||
"offset": [1335.8909766107738, 692.7345403667316]
|
||||
},
|
||||
"frontendVersion": "1.45.4"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -285,12 +285,10 @@ export class ComfyPage {
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true,
|
||||
url
|
||||
mockReleases = true
|
||||
}: {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
url?: string
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
@@ -322,7 +320,7 @@ export class ComfyPage {
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto({ url })
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
@@ -349,8 +347,8 @@ export class ComfyPage {
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto({ url }: { url?: string } = {}) {
|
||||
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
|
||||
async goto() {
|
||||
await this.page.goto(this.url)
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(uploadCount, 'should upload exactly once').toBe(1)
|
||||
})
|
||||
|
||||
test('Empty canvas uploads a transparent placeholder on serialization', async ({
|
||||
test('Empty canvas does not upload on serialization', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let uploadCount = 0
|
||||
@@ -566,10 +566,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
|
||||
expect(
|
||||
uploadCount,
|
||||
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
|
||||
).toBe(1)
|
||||
expect(uploadCount, 'empty canvas should not upload').toBe(0)
|
||||
})
|
||||
|
||||
test('Upload failure shows error toast', async ({ comfyPage }) => {
|
||||
|
||||
@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?share=test-share-id'
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
|
||||
@@ -1133,108 +1133,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
test('should keep widget-input link aligned after persisted-workflow reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'vueNodes/ksampler-denoise-widget-link'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
await comfyPage.workflow.waitForDraftPersisted()
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
|
||||
const ksampler = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
|
||||
if (!node) return null
|
||||
const findIndex = (name: string) =>
|
||||
node.inputs.findIndex(
|
||||
(input) => input.name === name || input.widget?.name === name
|
||||
)
|
||||
return {
|
||||
id: node.id,
|
||||
denoiseIndex: findIndex('denoise'),
|
||||
schedulerIndex: findIndex('scheduler')
|
||||
}
|
||||
})
|
||||
if (!ksampler) {
|
||||
throw new Error('KSampler should be present in fixture')
|
||||
}
|
||||
expect(
|
||||
ksampler.denoiseIndex,
|
||||
'denoise input slot not found'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
ksampler.schedulerIndex,
|
||||
'scheduler input slot not found'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const denoiseSlot = slotLocator(
|
||||
comfyPage.page,
|
||||
ksampler.id,
|
||||
ksampler.denoiseIndex,
|
||||
true
|
||||
)
|
||||
const schedulerSlot = slotLocator(
|
||||
comfyPage.page,
|
||||
ksampler.id,
|
||||
ksampler.schedulerIndex,
|
||||
true
|
||||
)
|
||||
await expectVisibleAll(denoiseSlot, schedulerSlot)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
|
||||
)
|
||||
.toMatchObject({
|
||||
targetId: ksampler.id,
|
||||
targetSlot: ksampler.denoiseIndex
|
||||
})
|
||||
|
||||
// If the regression returns, getInputPos stays stale relative to the
|
||||
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
|
||||
// positions each retry so layout settle doesn't cause flakes.
|
||||
await expect(async () => {
|
||||
const linkEnd = await comfyPage.page.evaluate(
|
||||
([nodeId, targetSlotIndex]) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
const slotPos = node.getInputPos(targetSlotIndex)
|
||||
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
|
||||
slotPos[0],
|
||||
slotPos[1]
|
||||
])
|
||||
const rect = window.app!.canvas.canvas.getBoundingClientRect()
|
||||
return { x: cx + rect.left, y: cy + rect.top }
|
||||
},
|
||||
[ksampler.id, ksampler.denoiseIndex] as const
|
||||
)
|
||||
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
|
||||
|
||||
const denoiseCenter = await getCenter(denoiseSlot)
|
||||
const schedulerCenter = await getCenter(schedulerSlot)
|
||||
const distToDenoise = Math.hypot(
|
||||
linkEnd!.x - denoiseCenter.x,
|
||||
linkEnd!.y - denoiseCenter.y
|
||||
)
|
||||
const rowGap = Math.hypot(
|
||||
denoiseCenter.x - schedulerCenter.x,
|
||||
denoiseCenter.y - schedulerCenter.y
|
||||
)
|
||||
|
||||
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
|
||||
// toward scheduler fails well before reaching it.
|
||||
expect(
|
||||
distToDenoise,
|
||||
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
|
||||
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
|
||||
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
|
||||
).toBeLessThan(rowGap / 4)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 1.6 KiB |
@@ -349,75 +349,25 @@ describe('usePainter', () => {
|
||||
})
|
||||
|
||||
describe('serializeValue', () => {
|
||||
it('returns existing modelValue when not dirty (preserves workflow-restored mask reference across WidgetPainter remount)', async () => {
|
||||
it('returns empty string when canvas has no strokes', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
mountPainter('test-node', 'painter/existing.png [temp]')
|
||||
mountPainter()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
expect(result).toBe('painter/existing.png [temp]')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
|
||||
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
fetchApiMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({ name: 'uploaded.png' })
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
const { modelValue } = mountPainter()
|
||||
modelValue.value = 'painter/existing.png [temp]'
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result).toBe('painter/uploaded.png [temp]')
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
mountPainter('test-node', 'painter/cached.png [temp]')
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
expect(result).toBe('painter/cached.png [temp]')
|
||||
})
|
||||
|
||||
it('clears the cached upload reference when the user clears the canvas', () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
getContext: vi.fn(() => ({
|
||||
clearRect: vi.fn()
|
||||
}))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { painter, canvasEl, modelValue } = mountPainter(
|
||||
'test-node',
|
||||
'painter/old-upload.png [temp]'
|
||||
)
|
||||
canvasEl.value = fakeCanvas
|
||||
|
||||
painter.handleClear()
|
||||
|
||||
expect(modelValue.value).toBe('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
let baseCanvas: HTMLCanvasElement | null = null
|
||||
let baseCtx: CanvasRenderingContext2D | null = null
|
||||
let hasBaseSnapshot = false
|
||||
let hasStrokes = false
|
||||
|
||||
let dirtyX0 = 0
|
||||
let dirtyY0 = 0
|
||||
@@ -412,6 +413,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
|
||||
isDrawing = true
|
||||
isDirty.value = true
|
||||
hasStrokes = true
|
||||
snapshotBrush()
|
||||
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
|
||||
strokeProcessor.addPoint(point)
|
||||
@@ -511,7 +513,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
if (!el || !ctx) return
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
isDirty.value = true
|
||||
modelValue.value = ''
|
||||
hasStrokes = false
|
||||
}
|
||||
|
||||
function updateCursorPos(e: PointerEvent) {
|
||||
@@ -617,11 +619,17 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
return { filename, subfolder, type }
|
||||
}
|
||||
|
||||
function isCanvasEmpty(): boolean {
|
||||
return !hasStrokes
|
||||
}
|
||||
|
||||
async function serializeValue(): Promise<string> {
|
||||
const el = canvasEl.value
|
||||
if (!el) return modelValue.value
|
||||
if (!el) return ''
|
||||
|
||||
if (!isDirty.value && modelValue.value) return modelValue.value
|
||||
if (isCanvasEmpty()) return ''
|
||||
|
||||
if (!isDirty.value) return modelValue.value
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
el.toBlob(resolve, 'image/png')
|
||||
@@ -709,6 +717,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
mainCtx = null
|
||||
getCtx()?.drawImage(img, 0, 0)
|
||||
isDirty.value = false
|
||||
hasStrokes = true
|
||||
}
|
||||
img.onerror = () => {
|
||||
modelValue.value = ''
|
||||
|
||||
@@ -76,25 +76,15 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const commandStoreMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: commandStoreMocks.execute
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
get query() {
|
||||
return routeMocks.query
|
||||
}
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn()
|
||||
@@ -107,30 +97,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
payloads: {} as Record<string, Record<string, string> | undefined>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn(
|
||||
(namespace: string, query: Record<string, unknown> = {}) => {
|
||||
const payload = preservedQueryMocks.payloads[namespace]
|
||||
if (!payload) return undefined
|
||||
const next: Record<string, unknown> = { ...query }
|
||||
let changed = false
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (typeof next[key] === 'string') continue
|
||||
next[key] = value
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : undefined
|
||||
}
|
||||
)
|
||||
mergePreservedQueryIntoQuery: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
|
||||
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -205,9 +178,6 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
openWorkflowMock.mockReset()
|
||||
loadBlankWorkflowMock.mockReset()
|
||||
commandStoreMocks.execute.mockReset()
|
||||
routeMocks.query = {}
|
||||
preservedQueryMocks.payloads = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -387,43 +357,4 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDefaultWorkflow', () => {
|
||||
it('opens templates browser for first-time users', async () => {
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(loadBlankWorkflowMock).toHaveBeenCalled()
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open templates browser when share param is in URL', async () => {
|
||||
routeMocks.query = { share: 'test-share-id' }
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(loadBlankWorkflowMock).toHaveBeenCalled()
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
|
||||
// No-local-user flow: ?share=... was captured into sessionStorage and the
|
||||
// URL query was dropped during the /user-select redirect before
|
||||
// initializeWorkflow() runs.
|
||||
preservedQueryMocks.payloads.share = { share: 'test-share-id' }
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(loadBlankWorkflowMock).toHaveBeenCalled()
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,6 @@ export function useWorkflowPersistenceV2() {
|
||||
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
|
||||
const templateUrlLoader = useTemplateUrlLoader()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
const tabState = useWorkflowTabState()
|
||||
const toast = useToast()
|
||||
@@ -161,20 +160,11 @@ export function useWorkflowPersistenceV2() {
|
||||
})
|
||||
}
|
||||
|
||||
const hasSharedWorkflowIntent = () => {
|
||||
if (typeof route.query.share === 'string') return true
|
||||
hydratePreservedQuery(SHARE_NAMESPACE)
|
||||
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
|
||||
return typeof merged?.share === 'string'
|
||||
}
|
||||
|
||||
const loadDefaultWorkflow = async () => {
|
||||
if (!settingStore.get('Comfy.TutorialCompleted')) {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
if (!hasSharedWorkflowIntent()) {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
@@ -135,9 +134,4 @@ const {
|
||||
processedWidgets,
|
||||
showAdvanced
|
||||
} = useProcessedWidgets(() => nodeData)
|
||||
|
||||
// Tracks widget-row growth that the node-level RO can't see
|
||||
if (nodeData?.id != null) {
|
||||
useVueElementTracking(String(nodeData.id), 'widgets-grid')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,10 +29,7 @@ const raf = createRafBatch(() => {
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
|
||||
export function scheduleSlotLayoutSync(nodeId: string) {
|
||||
// Drop signals for unregistered nodes (e.g. preview nodes with synthetic
|
||||
// ids from LGraphNodePreview) - they'd otherwise pump setDirty per RAF.
|
||||
if (!useNodeSlotRegistryStore().getNode(nodeId)) return
|
||||
function scheduleSlotLayoutSync(nodeId: string) {
|
||||
pendingNodes.add(nodeId)
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
@@ -43,8 +43,7 @@ const testState = vi.hoisted(() => ({
|
||||
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn(),
|
||||
scheduleSlotLayoutSync: vi.fn()
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
@@ -74,7 +73,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./useSlotElementTracking', () => ({
|
||||
scheduleSlotLayoutSync: testState.scheduleSlotLayoutSync,
|
||||
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
|
||||
}))
|
||||
|
||||
@@ -161,7 +159,6 @@ describe('useVueNodeResizeTracking', () => {
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
testState.scheduleSlotLayoutSync.mockReset()
|
||||
resizeObserverState.observe.mockReset()
|
||||
resizeObserverState.unobserve.mockReset()
|
||||
resizeObserverState.disconnect.mockReset()
|
||||
@@ -320,25 +317,4 @@ describe('useVueNodeResizeTracking', () => {
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('widgets-grid resize schedules a slot resync without writing node bounds', () => {
|
||||
const parentNodeId: NodeId = 'parent-node'
|
||||
const element = document.createElement('div')
|
||||
element.dataset.widgetsGridNodeId = parentNodeId
|
||||
const boxSizes = [{ inlineSize: 200, blockSize: 80 }]
|
||||
const entry = {
|
||||
target: element,
|
||||
borderBoxSize: boxSizes,
|
||||
contentBoxSize: boxSizes,
|
||||
devicePixelContentBoxSize: boxSizes,
|
||||
contentRect: new DOMRect(0, 0, 200, 80)
|
||||
} satisfies ResizeEntryLike
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.scheduleSlotLayoutSync).toHaveBeenCalledWith(parentNodeId)
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,10 +24,7 @@ import {
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import {
|
||||
scheduleSlotLayoutSync,
|
||||
syncNodeSlotLayoutsFromDOM
|
||||
} from './useSlotElementTracking'
|
||||
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
|
||||
|
||||
/**
|
||||
* Generic update item for element bounds tracking
|
||||
@@ -50,14 +47,14 @@ interface CachedNodeMeasurement {
|
||||
interface ElementTrackingConfig {
|
||||
/** Data attribute name (e.g., 'nodeId') */
|
||||
dataAttribute: string
|
||||
/** Handler for processing bounds updates. Omit for signal-only entries. */
|
||||
updateHandler?: (updates: ElementBoundsUpdate[]) => void
|
||||
/** Handler for processing bounds updates */
|
||||
updateHandler: (updates: ElementBoundsUpdate[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tracking configurations by element type
|
||||
*/
|
||||
const trackingConfigs = new Map<string, ElementTrackingConfig>([
|
||||
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
[
|
||||
'node',
|
||||
{
|
||||
@@ -70,10 +67,7 @@ const trackingConfigs = new Map<string, ElementTrackingConfig>([
|
||||
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||
}
|
||||
}
|
||||
],
|
||||
// Signal-only: outer node stays at its persisted min-h floor during
|
||||
// widget hydration, so the inner grid's RO is the only slot-drift signal.
|
||||
['widgets-grid', { dataAttribute: 'widgetsGridNodeId' }]
|
||||
]
|
||||
])
|
||||
|
||||
// Elements whose ResizeObserver fired while the tab was hidden
|
||||
@@ -127,14 +121,6 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
const element = entry.target
|
||||
|
||||
// Signal-only widgets-grid resize - route the parent node through the
|
||||
// slot-layout pipeline and skip bounds processing entirely.
|
||||
const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
|
||||
if (widgetsGridParentNodeId) {
|
||||
scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find which type this element belongs to
|
||||
let elementType: string | undefined
|
||||
let elementId: string | undefined
|
||||
@@ -252,7 +238,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config?.updateHandler && updates.length) config.updateHandler(updates)
|
||||
if (config && updates.length) config.updateHandler(updates)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,600 +1,43 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { getExtraOptionsForWidget } from '@/services/litegraphService'
|
||||
|
||||
async function invokeMenuCallback(option: IContextMenuValue): Promise<void> {
|
||||
// Production callbacks under test do not reference `this`; ContextMenuDivElement
|
||||
// is a DOM element decorated with extra fields, not realistic to construct in tests.
|
||||
await option.callback?.call({} as ContextMenuDivElement)
|
||||
}
|
||||
|
||||
const mockPrompt = vi.fn()
|
||||
const mockCanvas = vi.hoisted(() => ({
|
||||
setDirty: vi.fn(),
|
||||
graph_mouse: [100, 200],
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0] as [number, number],
|
||||
visible_area: [0, 0, 800, 600] as
|
||||
| [number, number, number, number]
|
||||
| undefined,
|
||||
fitToBounds: vi.fn()
|
||||
},
|
||||
graph: {
|
||||
nodes: [] as unknown[],
|
||||
getNodeById: vi.fn(),
|
||||
add: vi.fn(),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
isRootGraph: true
|
||||
},
|
||||
animateToBounds: vi.fn(),
|
||||
_deserializeItems: vi.fn()
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: undefined as unknown,
|
||||
graph: undefined as unknown,
|
||||
dragOverNode: null,
|
||||
lastExecutionError: null,
|
||||
rootGraph: {}
|
||||
}))
|
||||
|
||||
const mockFavoritedWidgetsStore = vi.hoisted(() => ({
|
||||
isFavorited: vi.fn().mockReturnValue(false),
|
||||
toggleFavorite: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
useFavoritedWidgetsStore: () => mockFavoritedWidgetsStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
prompt: mockPrompt
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: mockCanvas,
|
||||
getCanvas: () => mockCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
|
||||
addWidgetPromotionOptions: vi.fn(),
|
||||
isPreviewPseudoWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
normalizeI18nKey: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp,
|
||||
ComfyApp: {
|
||||
clipspace: null,
|
||||
clipspace_return_node: null,
|
||||
copyToClipspace: vi.fn(),
|
||||
pasteFromClipspace: vi.fn()
|
||||
}
|
||||
app: { canvas: undefined },
|
||||
ComfyApp: class {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: vi.fn() })
|
||||
}))
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({ widgets: new Map() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
nodeLocationProgressStates: {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeSubgraph: null,
|
||||
nodeIdToNodeLocatorId: (id: string) => id
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue(false)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
toggleSelectedNodesMode: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
invokeExtensionsAsync: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
typePrefix: 'Subgraph::',
|
||||
getBlueprint: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeOutputs: vi.fn(),
|
||||
getNodePreviews: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeAnimatedImage', () => ({
|
||||
useNodeAnimatedImage: () => ({
|
||||
showAnimatedPreview: vi.fn(),
|
||||
removeAnimatedPreview: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeCanvasImagePreview', () => ({
|
||||
useNodeCanvasImagePreview: () => ({
|
||||
showCanvasImagePreview: vi.fn(),
|
||||
removeCanvasImagePreview: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeImage', () => ({
|
||||
useNodeImage: () => ({ showPreview: vi.fn() }),
|
||||
useNodeVideo: () => ({ showPreview: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({ unpackSubgraph: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
|
||||
useMaskEditor: () => ({ openMaskEditor: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
registerWidget: vi.fn(),
|
||||
unregisterWidget: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/promotionStore', () => ({
|
||||
usePromotionStore: () => ({
|
||||
getPromotionsRef: vi.fn().mockReturnValue([])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/subgraphPseudoWidgetCache', () => ({
|
||||
resolveSubgraphPseudoWidgetCache: vi.fn().mockReturnValue({
|
||||
cache: { promotions: [], entries: [], nodes: [] },
|
||||
nodes: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({ openPanel: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
isComponentWidget: vi.fn().mockReturnValue(false),
|
||||
isDOMWidget: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const mockCreateBounds = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
createBounds: mockCreateBounds
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/ui', () => ({
|
||||
$el: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isAnimatedOutput: vi.fn().mockReturnValue(false),
|
||||
isImageNode: vi.fn().mockReturnValue(false),
|
||||
isVideoNode: vi.fn().mockReturnValue(false),
|
||||
isVideoOutput: vi.fn().mockReturnValue(false),
|
||||
migrateWidgetsValues: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/widgets/dynamicWidgets', () => ({
|
||||
applyDynamicInputs: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('@/schemas/nodeDef/migration', () => ({
|
||||
transformInputSpecV2ToV1: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/utils/nodeDefOrderingUtil', () => ({
|
||||
getOrderedInputSpecs: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
ComfyNodeDefImpl: vi.fn().mockImplementation((def: unknown) => def)
|
||||
}))
|
||||
|
||||
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
const node = new LGraphNode('TestNode')
|
||||
Object.assign(node, {
|
||||
id: 1,
|
||||
inputs: [],
|
||||
graph: null,
|
||||
getWidgetOnPos: vi.fn()
|
||||
})
|
||||
Object.assign(node, overrides)
|
||||
// Set static nodeData for tests that check constructor.nodeData
|
||||
;(node.constructor as { nodeData?: { name: string } }).nodeData = {
|
||||
name: 'TestNode'
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function createMockWidget(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
label: undefined,
|
||||
value: 42,
|
||||
callback: vi.fn(),
|
||||
options: {},
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
describe('litegraphService', () => {
|
||||
describe('useLitegraphService().getCanvasCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
|
||||
mockPrompt.mockReset()
|
||||
mockCreateBounds.mockReset()
|
||||
mockCanvas.graph.getNodeById.mockReset()
|
||||
mockCanvas.ds.scale = 1
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.visible_area = [0, 0, 800, 600]
|
||||
mockCanvas.graph.nodes = []
|
||||
mockApp.canvas = mockCanvas
|
||||
mockApp.graph = mockCanvas.graph
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('getExtraOptionsForWidget', () => {
|
||||
it('adds favorite option when widget is not favorited', () => {
|
||||
const node = createMockNode()
|
||||
const widget = createMockWidget()
|
||||
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
|
||||
it('returns origin when canvas is not yet initialised', () => {
|
||||
Reflect.set(app, 'canvas', undefined)
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].content).toContain('contextMenu.FavoriteWidget')
|
||||
expect(options[0].content).toContain('test_widget')
|
||||
})
|
||||
|
||||
it('adds unfavorite option when widget is already favorited', () => {
|
||||
const node = createMockNode()
|
||||
const widget = createMockWidget()
|
||||
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(true)
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
expect(options[0].content).toContain('contextMenu.UnfavoriteWidget')
|
||||
})
|
||||
|
||||
it('uses widget label when available', () => {
|
||||
const node = createMockNode()
|
||||
const widget = createMockWidget({ label: 'My Label' })
|
||||
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
expect(options[0].content).toContain('My Label')
|
||||
})
|
||||
|
||||
it('calls toggleFavorite when favorite option callback is invoked', () => {
|
||||
const node = createMockNode()
|
||||
const widget = createMockWidget()
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
void invokeMenuCallback(options[0])
|
||||
expect(mockFavoritedWidgetsStore.toggleFavorite).toHaveBeenCalledWith(
|
||||
node,
|
||||
'test_widget'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds rename option when input matches widget', () => {
|
||||
const widget = createMockWidget({ name: 'seed' })
|
||||
const node = createMockNode({
|
||||
inputs: [{ widget: { name: 'seed' } }]
|
||||
})
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
// rename is unshifted first, then favorite is unshifted (ends up first)
|
||||
expect(options).toHaveLength(2)
|
||||
const renameOption = options.find((o: IContextMenuValue) =>
|
||||
o.content?.includes('contextMenu.RenameWidget')
|
||||
)
|
||||
expect(renameOption).toBeDefined()
|
||||
expect(renameOption!.content).toContain('seed')
|
||||
})
|
||||
|
||||
it('rename callback updates widget and input labels', async () => {
|
||||
const widget = createMockWidget({ name: 'seed' })
|
||||
const input = { widget: { name: 'seed' }, label: undefined as unknown }
|
||||
const node = createMockNode({ inputs: [input] })
|
||||
mockPrompt.mockResolvedValue('New Name')
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
const renameOption = options.find((o: IContextMenuValue) =>
|
||||
o.content?.includes('contextMenu.RenameWidget')
|
||||
)
|
||||
await invokeMenuCallback(renameOption!)
|
||||
|
||||
expect(widget.label).toBe('New Name')
|
||||
expect(input.label).toBe('New Name')
|
||||
expect(widget.callback).toHaveBeenCalledWith(42)
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('rename callback clears label when empty string is returned', async () => {
|
||||
const widget = createMockWidget({ name: 'seed', label: 'Old' })
|
||||
const input = {
|
||||
widget: { name: 'seed' },
|
||||
label: 'Old' as string | undefined
|
||||
}
|
||||
const node = createMockNode({ inputs: [input] })
|
||||
mockPrompt.mockResolvedValue('')
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
const renameOption = options.find((o: IContextMenuValue) =>
|
||||
o.content?.includes('contextMenu.RenameWidget')
|
||||
)
|
||||
await invokeMenuCallback(renameOption!)
|
||||
|
||||
expect(widget.label).toBeUndefined()
|
||||
expect(input.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rename callback does nothing when prompt is cancelled', async () => {
|
||||
const widget = createMockWidget({ name: 'seed', label: 'Original' })
|
||||
const input = { widget: { name: 'seed' }, label: 'Original' }
|
||||
const node = createMockNode({ inputs: [input] })
|
||||
mockPrompt.mockResolvedValue(null)
|
||||
|
||||
const options = getExtraOptionsForWidget(node, widget)
|
||||
|
||||
const renameOption = options.find((o: IContextMenuValue) =>
|
||||
o.content?.includes('contextMenu.RenameWidget')
|
||||
)
|
||||
await invokeMenuCallback(renameOption!)
|
||||
|
||||
expect(widget.label).toBe('Original')
|
||||
expect(input.label).toBe('Original')
|
||||
})
|
||||
|
||||
it('adds promotion options when node is in a subgraph', async () => {
|
||||
const { addWidgetPromotionOptions } = vi.mocked(
|
||||
await import('@/core/graph/subgraph/promotionUtils')
|
||||
)
|
||||
const node = createMockNode({
|
||||
graph: { isRootGraph: false }
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
expect(addWidgetPromotionOptions).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not add promotion options on root graph', async () => {
|
||||
const { addWidgetPromotionOptions } = vi.mocked(
|
||||
await import('@/core/graph/subgraph/promotionUtils')
|
||||
)
|
||||
const node = createMockNode({ graph: null })
|
||||
const widget = createMockWidget()
|
||||
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
expect(addWidgetPromotionOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
describe('useLitegraphService', () => {
|
||||
// Lazily import to ensure mocks are in place
|
||||
async function getService() {
|
||||
const { useLitegraphService } =
|
||||
await import('@/services/litegraphService')
|
||||
return useLitegraphService()
|
||||
}
|
||||
it('returns origin when canvas exists but ds.visible_area is missing', () => {
|
||||
Reflect.set(app, 'canvas', { ds: {} })
|
||||
|
||||
describe('getCanvasCenter', () => {
|
||||
it('returns center of visible area', async () => {
|
||||
const service = await getService()
|
||||
// visible_area = [0, 0, 800, 600], dpi = 1
|
||||
const center = service.getCanvasCenter()
|
||||
expect(center).toEqual([400, 300])
|
||||
})
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
it('accounts for visible area offset', async () => {
|
||||
const saved = mockCanvas.ds.visible_area
|
||||
mockCanvas.ds.visible_area = [10, 20, 200, 100]
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
const service = await getService()
|
||||
const center = service.getCanvasCenter()
|
||||
expect(center).toEqual([110, 70])
|
||||
|
||||
mockCanvas.ds.visible_area = saved
|
||||
})
|
||||
|
||||
it('returns [0, 0] when no visible area', async () => {
|
||||
const savedVisibleArea = mockCanvas.ds.visible_area
|
||||
mockCanvas.ds.visible_area = undefined
|
||||
|
||||
const service = await getService()
|
||||
const center = service.getCanvasCenter()
|
||||
expect(center).toEqual([0, 0])
|
||||
|
||||
mockCanvas.ds.visible_area = savedVisibleArea
|
||||
})
|
||||
|
||||
it('returns [0, 0] without throwing when app.canvas is undefined', async () => {
|
||||
mockApp.canvas = undefined
|
||||
|
||||
const service = await getService()
|
||||
expect(() => service.getCanvasCenter()).not.toThrow()
|
||||
expect(service.getCanvasCenter()).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] }
|
||||
})
|
||||
|
||||
describe('resetView', () => {
|
||||
it('resets canvas scale and offset', async () => {
|
||||
mockCanvas.ds.scale = 2.5
|
||||
mockCanvas.ds.offset = [100, 200]
|
||||
const service = await getService()
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
service.resetView()
|
||||
|
||||
expect(mockCanvas.ds.scale).toBe(1)
|
||||
expect(mockCanvas.ds.offset).toEqual([0, 0])
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('goToNode', () => {
|
||||
it('animates to node bounds when node exists', async () => {
|
||||
const bounds = [10, 20, 100, 50]
|
||||
const graphNode = { boundingRect: bounds }
|
||||
mockCanvas.graph.getNodeById.mockReturnValue(graphNode)
|
||||
|
||||
const service = await getService()
|
||||
service.goToNode(42)
|
||||
|
||||
expect(mockCanvas.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
|
||||
it('does nothing when node does not exist', async () => {
|
||||
mockCanvas.graph.getNodeById.mockReturnValue(null)
|
||||
|
||||
const service = await getService()
|
||||
service.goToNode(999)
|
||||
|
||||
expect(mockCanvas.animateToBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitView', () => {
|
||||
it('calls fitToBounds and setDirty', async () => {
|
||||
const mockBounds = [0, 0, 500, 400]
|
||||
mockCreateBounds.mockReturnValue(mockBounds)
|
||||
|
||||
const nodeObj = {
|
||||
boundingRect: [0, 0, 100, 50],
|
||||
updateArea: vi.fn()
|
||||
}
|
||||
mockCanvas.graph.nodes = [nodeObj]
|
||||
|
||||
const service = await getService()
|
||||
service.fitView()
|
||||
|
||||
expect(mockCanvas.ds.fitToBounds).toHaveBeenCalledWith(mockBounds)
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('calls updateArea for nodes with zero bounds', async () => {
|
||||
mockCreateBounds.mockReturnValue([0, 0, 100, 100])
|
||||
|
||||
const nodeObj = {
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
updateArea: vi.fn()
|
||||
}
|
||||
mockCanvas.graph.nodes = [nodeObj]
|
||||
|
||||
const service = await getService()
|
||||
service.fitView()
|
||||
|
||||
expect(nodeObj.updateArea).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when createBounds returns null', async () => {
|
||||
mockCreateBounds.mockReturnValue(null)
|
||||
mockCanvas.graph.nodes = []
|
||||
|
||||
const service = await getService()
|
||||
service.fitView()
|
||||
|
||||
expect(mockCanvas.ds.fitToBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePreviews', () => {
|
||||
it('catches errors and logs them', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockNodeOutputStore.getNodeOutputs.mockImplementation(() => {
|
||||
throw new Error('test error')
|
||||
})
|
||||
|
||||
const service = await getService()
|
||||
const badNode = createMockNode({ flags: { collapsed: false } })
|
||||
expect(() => service.updatePreviews(badNode)).not.toThrow()
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error drawing node background',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips collapsed nodes', async () => {
|
||||
const service = await getService()
|
||||
const node = createMockNode({
|
||||
flags: { collapsed: true },
|
||||
imgs: undefined,
|
||||
images: undefined,
|
||||
preview: undefined
|
||||
})
|
||||
|
||||
service.updatePreviews(node)
|
||||
|
||||
expect(mockNodeOutputStore.getNodeOutputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(center).toEqual([110, 70])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
@@ -57,8 +56,7 @@ vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: vi.fn((fn) => {
|
||||
let cached: ReturnType<typeof fn>
|
||||
return (...args: Parameters<typeof fn>) => (cached ??= fn(...args))
|
||||
}),
|
||||
useDocumentVisibility: vi.fn(() => ref<'visible' | 'hidden'>('visible'))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
|
||||
Reference in New Issue
Block a user