mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
16 Commits
v1.43.3
...
test/subgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad4d42a7b0 | ||
|
|
fe1fae4de1 | ||
|
|
38aad02fb9 | ||
|
|
7c4234cf93 | ||
|
|
4bfc730a0e | ||
|
|
001916edf6 | ||
|
|
66daa6d645 | ||
|
|
32a53eeaee | ||
|
|
7c6ab19484 | ||
|
|
8f9fe3b21e | ||
|
|
4411d9a417 | ||
|
|
98d56bdada | ||
|
|
b4bb6d9d13 | ||
|
|
6a9fb4e1d5 | ||
|
|
2838edbb53 | ||
|
|
657ae6a6c3 |
407
browser_tests/assets/subgraphs/test-values-input-subgraph.json
Normal file
407
browser_tests/assets/subgraphs/test-values-input-subgraph.json
Normal file
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
|
||||
"revision": 0,
|
||||
"last_node_id": 19,
|
||||
"last_link_id": 24,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 14,
|
||||
"type": "CLIPLoader",
|
||||
"pos": [143.16716182216328, 290.16372862874033],
|
||||
"size": [270, 117.3125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [21]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPLoader"
|
||||
},
|
||||
"widgets_values": [null, "stable_diffusion", "default"]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1305.1455526601603, 472.17095792625025],
|
||||
"size": [225, 48],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 24
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"pos": [794.198171390827, 452.45433419677147],
|
||||
"size": [225, 172],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "renamed_clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 21
|
||||
},
|
||||
{
|
||||
"label": "renamed_seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"label": "renamed_vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 23
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [24]
|
||||
}
|
||||
],
|
||||
"title": "Input Test Subgraph",
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["12", "seed"],
|
||||
["15", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [155.04048166054417, 773.3816055422594],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [22]
|
||||
}
|
||||
],
|
||||
"title": "Seed Int",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAELoader",
|
||||
"pos": [163.6043676075426, 543.9624492717659],
|
||||
"size": [270, 82.65625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [23]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": ["pixel_space"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[21, 14, 0, 19, 0, "CLIP"],
|
||||
[22, 13, 0, 19, 1, "INT"],
|
||||
[23, 17, 0, 19, 2, "VAE"],
|
||||
[24, 19, 0, 18, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 19,
|
||||
"lastLinkId": 24,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Input Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
358.8694807105848, 439.23932667242485, 123.14453125,
|
||||
99.99999999999994
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [16],
|
||||
"localized_name": "clip",
|
||||
"label": "renamed_clip",
|
||||
"pos": [462.0140119605848, 459.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"localized_name": "seed",
|
||||
"label": "renamed_seed",
|
||||
"pos": [462.0140119605848, 479.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [19],
|
||||
"localized_name": "vae",
|
||||
"label": "renamed_vae",
|
||||
"pos": [462.0140119605848, 499.23932667242485]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [20],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1428.5510580294986, 483.2512895126797]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [769.2424728654022, 512.726159169824],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [18]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1208.5510580294986, 469.21581253470083],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 18
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [20]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [681.4596332342014, 243.17567172890932],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"label": "renamed_from_sidepanel",
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [17]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": 15,
|
||||
"origin_slot": 0,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": 12,
|
||||
"origin_slot": 0,
|
||||
"target_id": 16,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 15,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 12,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 16,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"origin_id": 16,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6727925600199565,
|
||||
"offset": [446.69747171876463, 99.95078257277316]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -91,6 +91,12 @@ export class CanvasHelper {
|
||||
await this.page.mouse.move(10, 10)
|
||||
}
|
||||
|
||||
async isReadOnly(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.state.readOnly
|
||||
})
|
||||
}
|
||||
|
||||
async getScale(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.ds.scale
|
||||
|
||||
@@ -28,10 +28,15 @@ export const TestIds = {
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -47,6 +52,13 @@ export const TestIds = {
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
widgetToggle: 'subgraph-widget-toggle',
|
||||
widgetLabel: 'subgraph-widget-label'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
@@ -76,6 +88,10 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -101,3 +117,4 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import { isSubgraph } from '../../src/utils/typeGuardUtil'
|
||||
|
||||
@@ -14,3 +16,30 @@ export function assertSubgraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget-input slot Y position and the node title height
|
||||
* for the promoted "text" input on the SubgraphNode.
|
||||
*
|
||||
* The slot Y should be at the widget row, not the header. A value near
|
||||
* zero or negative indicates the slot is positioned at the header (the bug).
|
||||
*/
|
||||
export function getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function pressKeyAndExpectRequest(
|
||||
comfyPage: ComfyPage,
|
||||
key: string,
|
||||
urlPattern: string,
|
||||
method: string = 'POST'
|
||||
) {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes(urlPattern) && req.method() === method,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await comfyPage.page.keyboard.press(key)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test.describe('Sidebar Toggle Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const sidebarTabs = [
|
||||
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
|
||||
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
|
||||
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
|
||||
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
|
||||
] as const
|
||||
|
||||
for (const { key, tabId, label } of sidebarTabs) {
|
||||
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
|
||||
const selectedButton = comfyPage.page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Canvas View Controls', () => {
|
||||
test("'Alt+=' zooms in", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeGreaterThan(initialScale)
|
||||
})
|
||||
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
||||
// Set scale very small so fit-view will zoom back to fit nodes
|
||||
await comfyPage.canvasOps.setScale(0.1)
|
||||
const scaleBefore = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleBefore).toBeCloseTo(0.1, 1)
|
||||
|
||||
// Click canvas to ensure focus is within graph-canvas-container
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfter = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfter).toBeGreaterThan(scaleBefore)
|
||||
})
|
||||
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node State Toggles', () => {
|
||||
test("'Alt+c' collapses and expands selected nodes", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
const getMode = () =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||
}, node.id)
|
||||
|
||||
expect(await getMode()).toBe(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
// NEVER (2) = muted
|
||||
expect(await getMode()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mode and Panel Toggles', () => {
|
||||
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Set up linearData so app mode has something to show
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Toggle off with Alt+m
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
||||
|
||||
// Toggle on again
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Queue and Execution', () => {
|
||||
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/prompt')
|
||||
})
|
||||
|
||||
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Shift+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
const body = request.postDataJSON()
|
||||
expect(body.front).toBe(true)
|
||||
})
|
||||
|
||||
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Alt+Enter',
|
||||
'/interrupt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/interrupt')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('File Operations', () => {
|
||||
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
|
||||
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
|
||||
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
|
||||
// Detect the file input click via an event listener.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.TestCommand = false
|
||||
const fileInputs =
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
|
||||
for (const input of fileInputs) {
|
||||
input.addEventListener('click', () => {
|
||||
window.TestCommand = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graph Operations', () => {
|
||||
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBeGreaterThan(1)
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After conversion, node count should decrease
|
||||
// (multiple nodes replaced by single subgraph node)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
test("'r' refreshes node definitions", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'KeyR',
|
||||
'/object_info',
|
||||
'GET'
|
||||
)
|
||||
expect(request.url()).toContain('/object_info')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -42,11 +42,13 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
@@ -75,7 +77,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
@@ -165,17 +169,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify Find on GitHub button is present in the error card
|
||||
const findOnGithubButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Find on GitHub'
|
||||
})
|
||||
const findOnGithubButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -204,7 +210,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -220,7 +226,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -231,13 +237,10 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
|
||||
@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.describe('Restore all open workflows on reload', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Restore workflow tabs after browser restart', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage fallback pointers to be written
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Simulate browser restart: clear sessionStorage (lost on close)
|
||||
// but keep localStorage (survives browser restart)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
test('Restores topbar workflow tabs after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
// Wait for both restored tabs to render (localStorage fallback is async)
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-tabs .workflow-label', {
|
||||
hasText: workflowA
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.EnableWorkflowViewRestore',
|
||||
|
||||
86
browser_tests/tests/subgraph-promoted-slot-position.spec.ts
Normal file
86
browser_tests/tests/subgraph-promoted-slot-position.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
57
browser_tests/tests/subgraph-promoted-widget-dom.spec.ts
Normal file
57
browser_tests/tests/subgraph-promoted-widget-dom.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
|
||||
// Widget top should be below the header bottom
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
})
|
||||
}
|
||||
)
|
||||
117
browser_tests/tests/subgraphInputSlotRename.spec.ts
Normal file
117
browser_tests/tests/subgraphInputSlotRename.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
const headerBox = await sgNode
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
const headerAfter = await sgNodeAfter
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetAfter = await seedWidgetAfter.boundingBox()
|
||||
expect(headerAfter).not.toBeNull()
|
||||
expect(widgetAfter).not.toBeNull()
|
||||
expect(widgetAfter!.y).toBeGreaterThan(
|
||||
headerAfter!.y + headerAfter!.height
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
146
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
146
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
if (!(await panel.isVisible())) {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
}
|
||||
await expect(panel).toBeVisible()
|
||||
return panel
|
||||
}
|
||||
|
||||
async function selectSubgraphAndOpenEditor(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const subgraphNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
await subgraphNode.click('title')
|
||||
|
||||
await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
|
||||
await expect(editorToggle).toBeVisible()
|
||||
await editorToggle.click()
|
||||
|
||||
const shownSection = comfyPage.page.getByTestId(
|
||||
TestIds.subgraphEditor.shownSection
|
||||
)
|
||||
await expect(shownSection).toBeVisible()
|
||||
return shownSection
|
||||
}
|
||||
|
||||
async function collectWidgetLabels(shownSection: Locator) {
|
||||
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
const count = await labels.count()
|
||||
const texts: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await labels.nth(i).textContent()
|
||||
if (text) texts.push(text.trim())
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget panel',
|
||||
{ tag: ['@node', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('SubgraphEditor (Settings panel)', () => {
|
||||
test('linked promoted widgets have hide toggle disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const toggleButtons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.widgetToggle
|
||||
)
|
||||
const count = await toggleButtons.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(toggleButtons.nth(i)).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
test('linked promoted widgets show link icon instead of eye icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const linkIcons = shownSection.locator('.icon-\\[lucide--link\\]')
|
||||
await expect(linkIcons.first()).toBeVisible()
|
||||
|
||||
const eyeIcons = shownSection.locator('.icon-\\[lucide--eye\\]')
|
||||
await expect(eyeIcons).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('widget labels display renamed values instead of raw names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/test-values-input-subgraph'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Input Test Subgraph'
|
||||
)
|
||||
|
||||
const allTexts = await collectWidgetLabels(shownSection)
|
||||
expect(allTexts.length).toBeGreaterThan(0)
|
||||
|
||||
// The fixture has a widget with name="text" but
|
||||
// label="renamed_from_sidepanel". The panel should show the
|
||||
// renamed label, not the raw widget name.
|
||||
expect(allTexts).toContain('renamed_from_sidepanel')
|
||||
expect(allTexts).not.toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Parameters tab (WidgetActions menu)', () => {
|
||||
test('linked promoted widget menu should not show Hide/Show input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const subgraphNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
|
||||
)[0]
|
||||
await subgraphNode.click('title')
|
||||
|
||||
const panel = await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const moreButtons = panel.locator('.icon-\\[lucide--more-vertical\\]')
|
||||
await expect(moreButtons.first()).toBeVisible()
|
||||
await moreButtons.first().click()
|
||||
|
||||
await expect(comfyPage.page.getByText('Hide input')).toHaveCount(0)
|
||||
await expect(comfyPage.page.getByText('Show input')).toHaveCount(0)
|
||||
await expect(comfyPage.page.getByText('Rename')).toBeVisible()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test(
|
||||
'select components in filter bar render correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for filter bar select components to render
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
||||
const filterBar = sortBySelect.locator(
|
||||
'xpath=ancestor::div[contains(@class, "justify-between")]'
|
||||
)
|
||||
await expect(filterBar).toHaveScreenshot(
|
||||
'template-filter-bar-select-components.png',
|
||||
{
|
||||
mask: [comfyPage.page.locator('.p-toast')]
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards descriptions adjust height dynamically',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
|
||||
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
|
||||
|
||||
// The small movement should have selected the node, not dragged it
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('should move node when pointer moves beyond drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move 50px — well beyond the 3px drag threshold
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
21
src/App.vue
21
src/App.vue
@@ -9,13 +9,10 @@ import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -23,7 +20,6 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -98,12 +94,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
// Disabled: Third-party custom node extensions frequently trigger this toast
|
||||
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
|
||||
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
|
||||
// the generic error message alarms users and offers no actionable guidance.
|
||||
// The console.error above still logs the details for developers to debug.
|
||||
// useToastStore().add({
|
||||
// severity: 'error',
|
||||
// summary: t('g.preloadErrorTitle'),
|
||||
// detail: t('g.preloadError'),
|
||||
// life: 10000
|
||||
// })
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
|
||||
@@ -19,10 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
/**
|
||||
* Open GitHub issues search and track telemetry.
|
||||
*/
|
||||
const openGitHubIssues = () => {
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
|
||||
@@ -49,7 +49,12 @@
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
|
||||
60
src/components/input/MultiSelect.test.ts
Normal file
60
src/components/input/MultiSelect.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
multiSelectDropdown: 'Multi-select dropdown',
|
||||
noResultsFound: 'No results found',
|
||||
search: 'Search',
|
||||
clearAll: 'Clear all',
|
||||
itemsSelected: 'Items selected'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('MultiSelect', () => {
|
||||
function createWrapper() {
|
||||
return mount(MultiSelect, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
},
|
||||
props: {
|
||||
modelValue: [],
|
||||
label: 'Category',
|
||||
options: [
|
||||
{ name: 'One', value: 'one' },
|
||||
{ name: 'Two', value: 'two' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps open-state border styling available while the dropdown is open', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
|
||||
|
||||
expect(trigger.classes()).toContain(
|
||||
'data-[state=open]:border-node-component-border'
|
||||
)
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('aria-expanded')).toBe('true')
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,207 +1,215 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||
1. Our value template only shows a static label (not dynamic based on selection)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
<MultiSelect
|
||||
<ComboboxRoot
|
||||
v-model="selectedItems"
|
||||
v-bind="{ ...$attrs, options: filteredOptions }"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
|
||||
'focus-within:border-base-foreground',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class: cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
},
|
||||
dropdown: {
|
||||
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
showSearchBox || showSelectedCount || showClearButton
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background',
|
||||
'text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
context?.focused &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
pcOptionCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 pb-4 text-sm text-muted-foreground'
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
:tabindex="0"
|
||||
multiple
|
||||
by="value"
|
||||
:disabled
|
||||
ignore-filter
|
||||
:reset-search-term-on-select="false"
|
||||
>
|
||||
<template
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchInput
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="px-1 text-sm text-base-foreground"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
v-if="showClearButton"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click.stop="selectedItems = []"
|
||||
>
|
||||
{{ $t('g.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-border-default"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div
|
||||
role="button"
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:style="popoverStyle"
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger
|
||||
v-bind="$attrs"
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
:class="
|
||||
cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
selectedCount > 0
|
||||
? 'border-base-foreground'
|
||||
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
|
||||
disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.selected"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
/>
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{{ slotProps.option.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<div
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="popoverStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
@focus-outside="preventFocusDismiss"
|
||||
>
|
||||
<div
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
class="flex flex-col px-2 pt-2 pb-0"
|
||||
>
|
||||
<div
|
||||
v-if="showSearchBox"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
|
||||
(showSelectedCount || showClearButton) && 'mb-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
|
||||
/>
|
||||
<ComboboxInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder ?? t('g.search')"
|
||||
class="w-full border-none bg-transparent text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="px-1 text-sm text-base-foreground"
|
||||
>
|
||||
{{ $t('g.itemsSelected', { count: selectedCount }) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="showClearButton"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click.stop="selectedItems = []"
|
||||
>
|
||||
{{ $t('g.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-border-default" />
|
||||
</div>
|
||||
|
||||
<ComboboxViewport
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-0 p-0 text-sm',
|
||||
'scrollbar-custom overflow-y-auto',
|
||||
'min-w-(--reka-combobox-trigger-width)'
|
||||
)
|
||||
"
|
||||
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="opt in filteredOptions"
|
||||
:key="opt.value"
|
||||
:value="opt"
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
|
||||
>
|
||||
<ComboboxItemIndicator>
|
||||
<i
|
||||
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
|
||||
/>
|
||||
</ComboboxItemIndicator>
|
||||
</div>
|
||||
<span>{{ opt.name }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
|
||||
{{ $t('g.noResultsFound') }}
|
||||
</ComboboxEmpty>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import type { FocusOutsideEvent } from 'reka-ui'
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport
|
||||
} from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
type Option = SelectOption
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface Props {
|
||||
const {
|
||||
label,
|
||||
options = [],
|
||||
size = 'lg',
|
||||
disabled = false,
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Available options */
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Disable the select */
|
||||
disabled?: boolean
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
@@ -216,22 +224,9 @@ interface Props {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
// Note: options prop is intentionally omitted.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
const {
|
||||
label,
|
||||
size = 'lg',
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder = 'Search...',
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<Props>()
|
||||
}>()
|
||||
|
||||
const selectedItems = defineModel<Option[]>({
|
||||
const selectedItems = defineModel<SelectOption[]>({
|
||||
required: true
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
@@ -239,15 +234,16 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
const { t } = useI18n()
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
function preventFocusDismiss(event: FocusOutsideEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const popoverStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
const attrs = useAttrs()
|
||||
const originalOptions = computed(() => (attrs.options as Option[]) || [])
|
||||
|
||||
// Use VueUse's useFuse for better reactivity and performance
|
||||
const fuseOptions: UseFuseOptions<Option> = {
|
||||
const fuseOptions: UseFuseOptions<SelectOption> = {
|
||||
fuseOptions: {
|
||||
keys: ['name', 'value'],
|
||||
threshold: 0.3,
|
||||
@@ -256,23 +252,20 @@ const fuseOptions: UseFuseOptions<Option> = {
|
||||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
|
||||
const { results } = useFuse(searchQuery, () => options, fuseOptions)
|
||||
|
||||
// Filter options based on search, but always include selected items
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value || searchQuery.value.trim() === '') {
|
||||
return originalOptions.value
|
||||
return options
|
||||
}
|
||||
|
||||
// results.value already contains the search results from useFuse
|
||||
const searchResults = results.value.map(
|
||||
(result: { item: Option }) => result.item
|
||||
(result: { item: SelectOption }) => result.item
|
||||
)
|
||||
|
||||
// Include selected items that aren't in search results
|
||||
const selectedButNotInResults = selectedItems.value.filter(
|
||||
(item) =>
|
||||
!searchResults.some((result: Option) => result.value === item.value)
|
||||
!searchResults.some((result: SelectOption) => result.value === item.value)
|
||||
)
|
||||
|
||||
return [...selectedButNotInResults, ...searchResults]
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
1. Our custom value template needs options to look up labels from values
|
||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||
3. We need to maintain the icon slot functionality in the value template
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
-->
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: cn(
|
||||
<SelectRoot v-model="selectedItem" :disabled>
|
||||
<SelectTrigger
|
||||
v-bind="$attrs"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
@@ -23,121 +14,107 @@
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus-within:border-node-component-border',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
invalid ? 'border-destructive-background' : 'border-transparent',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
label: {
|
||||
class: cn(
|
||||
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
// Right chevron touch area
|
||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||
},
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: `max-height: min(${listMaxHeight}, 50vh)`,
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class:
|
||||
// Same list tone/size as MultiSelect
|
||||
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: cn(
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus state for keyboard navigation
|
||||
context.focused && 'bg-secondary-background-hover',
|
||||
// Selected state + check icon
|
||||
context.selected &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
optionLabel: {
|
||||
class: 'truncate'
|
||||
},
|
||||
optionGroupLabel: {
|
||||
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 py-2 text-sm text-muted-foreground'
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
:tabindex="0"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
|
||||
cn(
|
||||
'flex flex-1 items-center gap-2 overflow-hidden py-2',
|
||||
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
|
||||
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-base-foreground"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-base-foreground">
|
||||
{{ label }}
|
||||
</span>
|
||||
<SelectValue :placeholder="label" class="truncate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret (hidden when loading) -->
|
||||
<template #dropdownicon>
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="icon-[lucide--chevron-down] text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div
|
||||
class="flex w-full items-center justify-between gap-3"
|
||||
:style="optionStyle"
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="optionStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'min-w-(--reka-select-trigger-width)',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectViewport
|
||||
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
|
||||
class="scrollbar-custom w-full"
|
||||
>
|
||||
<SelectItem
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer items-center justify-between select-none',
|
||||
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus:bg-secondary-background-hover',
|
||||
'data-[state=checked]:bg-secondary-background-selected',
|
||||
'data-[state=checked]:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectItemText class="truncate">
|
||||
{{ opt.name }}
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator
|
||||
class="flex shrink-0 items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
@@ -152,16 +129,12 @@ const {
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
/**
|
||||
* Required for displaying the selected item's label.
|
||||
* Cannot rely on $attrs alone because we need to access options
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
@@ -169,6 +142,8 @@ const {
|
||||
invalid?: boolean
|
||||
/** Show loading spinner instead of chevron */
|
||||
loading?: boolean
|
||||
/** Disable the select */
|
||||
disabled?: boolean
|
||||
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||
listMaxHeight?: string
|
||||
/** Minimum width of the popover (default: auto) */
|
||||
@@ -181,26 +156,8 @@ const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Maps a value to its display label.
|
||||
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
|
||||
* only the raw value. We need this to show the correct text when an item is selected.
|
||||
*/
|
||||
const getLabel = (val: string | null | undefined) => {
|
||||
if (val == null) return label ?? ''
|
||||
if (!options) return label ?? ''
|
||||
const found = options.find((o) => o.value === val)
|
||||
return found ? found.name : (label ?? '')
|
||||
}
|
||||
|
||||
// Extract complex style logic from template
|
||||
const optionStyle = computed(() => {
|
||||
if (!popoverMinWidth && !popoverMaxWidth) return undefined
|
||||
|
||||
const styles: string[] = []
|
||||
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
|
||||
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
|
||||
|
||||
return styles.join('; ')
|
||||
const optionStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,15 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import { st } from '@/i18n'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -38,12 +41,21 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.isGraphReady) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingNodesErrorStore.missingAncestorExecutionIds
|
||||
)
|
||||
})
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
@@ -291,6 +303,7 @@ function handleTitleCancel() {
|
||||
v-if="isSingleSubgraphNode"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
data-testid="subgraph-editor-toggle"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@click="
|
||||
rightSidePanelStore.openPanel(
|
||||
|
||||
@@ -237,6 +237,11 @@ describe('ErrorNodeCard.vue', () => {
|
||||
|
||||
// Report is still generated with fallback log message
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverLogs: 'Failed to retrieve server logs'
|
||||
})
|
||||
)
|
||||
expect(wrapper.text()).toContain('ComfyUI Error Report')
|
||||
})
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
{{ t('g.findOnGithub') }}
|
||||
@@ -99,6 +100,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
{{ t('g.copy') }}
|
||||
@@ -125,12 +127,10 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const {
|
||||
@@ -154,10 +154,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const commandStore = useCommandStore()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -178,23 +176,6 @@ function handleCopyError(idx: number) {
|
||||
}
|
||||
|
||||
function handleCheckGithub(error: ErrorItem) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(error.message + ' is:issue')
|
||||
window.open(
|
||||
`${staticUrls.githubIssues}?q=${query}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
function handleGetHelp() {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
commandStore.execute('Comfy.ContactSupport')
|
||||
findOnGitHub(error.message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -42,23 +40,25 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockApplyChanges = vi.fn()
|
||||
const mockIsRestarting = ref(false)
|
||||
const mockApplyChanges = vi.hoisted(() => vi.fn())
|
||||
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
||||
useApplyChanges: () => ({
|
||||
isRestarting: mockIsRestarting,
|
||||
get isRestarting() {
|
||||
return mockIsRestarting.value
|
||||
},
|
||||
applyChanges: mockApplyChanges
|
||||
})
|
||||
}))
|
||||
|
||||
const mockIsPackInstalled = vi.fn(() => false)
|
||||
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShouldShowManagerButtons = { value: false }
|
||||
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: mockShouldShowManagerButtons
|
||||
@@ -128,7 +128,7 @@ function mountCard(
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
|
||||
}
|
||||
|
||||
@@ -209,12 +209,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Find the copy button by text (rendered inside ErrorNodeCard)
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
@@ -245,5 +242,9 @@ describe('TabErrors.vue', () => {
|
||||
// Should render in the dedicated runtime error panel, not inside accordion
|
||||
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
|
||||
expect(runtimePanel.exists()).toBe(true)
|
||||
// Verify the error message appears exactly once (not duplicated in accordion)
|
||||
expect(
|
||||
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@@ -209,12 +210,9 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
@@ -238,6 +236,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
@@ -246,7 +245,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
@@ -372,13 +371,13 @@ watch(
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
const hasMatch =
|
||||
group.type === 'execution' &&
|
||||
group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.title, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
@@ -418,20 +417,4 @@ function handleReplaceAll() {
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -80,8 +80,7 @@ describe('swapNodeGroups computed', () => {
|
||||
})
|
||||
|
||||
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes(nodeTypes)
|
||||
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const t = (key: string) => key
|
||||
|
||||
39
src/components/rightSidePanel/errors/useErrorActions.ts
Normal file
39
src/components/rightSidePanel/errors/useErrorActions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
export function useErrorActions() {
|
||||
const telemetry = useTelemetry()
|
||||
const commandStore = useCommandStore()
|
||||
const { staticUrls } = useExternalLink()
|
||||
|
||||
function openGitHubIssues() {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(errorMessage + ' is:issue')
|
||||
window.open(
|
||||
`${staticUrls.githubIssues}?q=${query}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
return { openGitHubIssues, contactSupport, findOnGitHub }
|
||||
}
|
||||
@@ -58,6 +58,7 @@ vi.mock(
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -126,8 +127,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups non-replaceable nodes by cnrId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
@@ -146,8 +148,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('excludes replaceable nodes from missingPackGroups', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -164,8 +167,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups nodes without cnrId under null packId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
|
||||
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
|
||||
])
|
||||
@@ -177,8 +181,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts groups alphabetically with null packId last', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
|
||||
makeMissingNodeType('NodeB', { nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
|
||||
@@ -190,8 +195,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
|
||||
@@ -206,8 +212,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('handles string nodeType entries', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
])
|
||||
await nextTick()
|
||||
@@ -224,8 +231,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing_node group when missing nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
@@ -237,8 +245,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes swap_nodes group when replaceable nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -253,8 +262,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes both swap_nodes and missing_node when both exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -272,8 +282,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('swap_nodes has lower priority than missing_node', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -533,13 +544,18 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing node group title as message', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -195,12 +196,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors
|
||||
.map((e: ErrorItem) => e.message)
|
||||
.join(' '),
|
||||
searchableDetails: card.errors
|
||||
.map((e: ErrorItem) => e.details ?? '')
|
||||
.join(' ')
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,6 +237,7 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
@@ -285,7 +283,7 @@ export function useErrorGroups(
|
||||
|
||||
const missingNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
@@ -407,7 +405,7 @@ export function useErrorGroups(
|
||||
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||
|
||||
const pendingTypes = computed(() =>
|
||||
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(n): n is Exclude<MissingNodeType, string> =>
|
||||
typeof n !== 'string' && !n.cnrId
|
||||
)
|
||||
@@ -448,6 +446,8 @@ export function useErrorGroups(
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
final.set(r.value.type, r.value.packId)
|
||||
} else {
|
||||
console.warn('Failed to resolve pack ID:', r.reason)
|
||||
}
|
||||
}
|
||||
// Clear any remaining RESOLVING markers for failed lookups
|
||||
@@ -459,8 +459,18 @@ export function useErrorGroups(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Evict stale entries when missing nodes are cleared
|
||||
watch(
|
||||
() => missingNodesStore.missingNodesError,
|
||||
(error) => {
|
||||
if (!error && asyncResolvedIds.value.size > 0) {
|
||||
asyncResolvedIds.value = new Map()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<
|
||||
string | null,
|
||||
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||
@@ -522,7 +532,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<string, SwapNodeGroup>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
@@ -546,7 +556,7 @@ export function useErrorGroups(
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = executionErrorStore.missingNodesError
|
||||
const error = missingNodesStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
|
||||
@@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
@@ -40,24 +42,33 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
if (runtimeErrors.length === 0) return
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch {
|
||||
return
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled || !systemStatsStore.systemStats) return
|
||||
|
||||
let logs: string
|
||||
try {
|
||||
logs = await api.getLogs()
|
||||
} catch {
|
||||
logs = 'Failed to retrieve server logs'
|
||||
}
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
|
||||
const workflow = app.rootGraph.serialize()
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
@@ -72,8 +83,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch {
|
||||
// Fallback: keep original error.details
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -223,6 +223,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
data-testid="subgraph-editor-shown-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
@@ -254,6 +255,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
data-testid="subgraph-editor-hidden-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -38,11 +38,14 @@ function getIcon() {
|
||||
<div class="line-clamp-1 text-xs text-text-secondary">
|
||||
{{ nodeTitle }}
|
||||
</div>
|
||||
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
|
||||
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
|
||||
{{ widgetName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
data-testid="subgraph-widget-toggle"
|
||||
:disabled="isPhysical"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
>
|
||||
|
||||
@@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
cleanup()
|
||||
expect(graph.onNodeAdded).toBe(originalHook)
|
||||
})
|
||||
|
||||
it('restores original node callbacks when a node is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('clip', 'CLIP')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const originalOnWidgetChanged = vi.fn()
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
node.onWidgetChanged = originalOnWidgetChanged
|
||||
graph.add(node)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
// Callbacks should be chained (not the originals)
|
||||
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
||||
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
||||
|
||||
// Simulate node removal via the graph hook
|
||||
graph.onNodeRemoved!(node)
|
||||
|
||||
// Original callbacks should be restored
|
||||
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
|
||||
})
|
||||
|
||||
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('clip', 'CLIP')
|
||||
graph.add(node)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
const chainedAfterFirst = node.onConnectionsChange
|
||||
|
||||
// Install again on the same graph — should be a no-op for existing nodes
|
||||
installErrorClearingHooks(graph)
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -35,10 +35,22 @@ function resolvePromotedExecId(
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
onConnectionsChange: LGraphNode['onConnectionsChange']
|
||||
onWidgetChanged: LGraphNode['onWidgetChanged']
|
||||
}
|
||||
|
||||
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
|
||||
|
||||
function installNodeHooks(node: LGraphNode): void {
|
||||
if (hookedNodes.has(node)) return
|
||||
hookedNodes.add(node)
|
||||
|
||||
originalCallbacks.set(node, {
|
||||
onConnectionsChange: node.onConnectionsChange,
|
||||
onWidgetChanged: node.onWidgetChanged
|
||||
})
|
||||
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
function (type, slotIndex, isConnected) {
|
||||
@@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
)
|
||||
}
|
||||
|
||||
function restoreNodeHooks(node: LGraphNode): void {
|
||||
const originals = originalCallbacks.get(node)
|
||||
if (!originals) return
|
||||
node.onConnectionsChange = originals.onConnectionsChange
|
||||
node.onWidgetChanged = originals.onWidgetChanged
|
||||
originalCallbacks.delete(node)
|
||||
hookedNodes.delete(node)
|
||||
}
|
||||
|
||||
function installNodeHooksRecursive(node: LGraphNode): void {
|
||||
installNodeHooks(node)
|
||||
if (node.isSubgraphNode?.()) {
|
||||
@@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void {
|
||||
}
|
||||
}
|
||||
|
||||
function restoreNodeHooksRecursive(node: LGraphNode): void {
|
||||
restoreNodeHooks(node)
|
||||
if (node.isSubgraphNode?.()) {
|
||||
for (const innerNode of node.subgraph._nodes ?? []) {
|
||||
restoreNodeHooksRecursive(innerNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
installNodeHooksRecursive(node)
|
||||
@@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
}
|
||||
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
restoreNodeHooksRecursive(node)
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
restoreNodeHooksRecursive(node)
|
||||
}
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with displayName="value" (subgraph input
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
|
||||
// SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -352,7 +356,8 @@ function safeWidgetMapper(
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -803,6 +808,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
src/composables/graph/useNodeErrorFlagSync.ts
Normal file
111
src/composables/graph/useNodeErrorFlagSync.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
||||
if (node.has_errors === hasErrors) return
|
||||
const oldValue = node.has_errors
|
||||
node.has_errors = hasErrors
|
||||
node.graph?.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
oldValue,
|
||||
newValue: hasErrors
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass reconciliation of node error flags.
|
||||
* Collects the set of nodes that should have errors, then walks all nodes
|
||||
* once, setting each flag exactly once. This avoids the redundant
|
||||
* true→false→true transition (and duplicate events) that a clear-then-apply
|
||||
* approach would cause.
|
||||
*/
|
||||
function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
const flaggedNodes = new Set<LGraphNode>()
|
||||
const errorSlots = new Map<LGraphNode, Set<string>>()
|
||||
|
||||
if (nodeErrors) {
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
const node = getNodeByExecutionId(rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
flaggedNodes.add(node)
|
||||
const slotNames = new Set<string>()
|
||||
for (const error of nodeError.errors) {
|
||||
const name = error.extra_info?.input_name
|
||||
if (name) slotNames.add(name)
|
||||
}
|
||||
if (slotNames.size > 0) errorSlots.set(node, slotNames)
|
||||
|
||||
for (const parentId of getParentExecutionIds(executionId)) {
|
||||
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
||||
if (parentNode) flaggedNodes.add(parentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const execId of missingModelExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
if (node.inputs) {
|
||||
const nodeSlotNames = errorSlots.get(node)
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
): () => void {
|
||||
const settingStore = useSettingStore()
|
||||
const showErrorsTab = computed(() =>
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
showErrorsTab
|
||||
],
|
||||
() => {
|
||||
if (!app.isGraphReady) return
|
||||
// Legacy (LGraphNode) only: suppress missing-model error flags when
|
||||
// the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
return stop
|
||||
}
|
||||
@@ -86,6 +86,24 @@ function useVueNodeLifecycleIndividual() {
|
||||
() => !shouldRenderVueNodes.value,
|
||||
() => {
|
||||
disposeNodeManagerAndSyncs()
|
||||
|
||||
// Force arrange() on all nodes so input.pos is computed before
|
||||
// the first legacy drawConnections frame (which may run before
|
||||
// drawNode on the foreground canvas).
|
||||
const graph = comfyApp.canvas?.graph
|
||||
if (!graph) {
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
return
|
||||
}
|
||||
for (const node of graph._nodes) {
|
||||
if (node.flags.collapsed) continue
|
||||
try {
|
||||
node.arrange()
|
||||
} catch {
|
||||
/* skip nodes not fully initialized */
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
65
src/composables/useClickDragGuard.test.ts
Normal file
65
src/composables/useClickDragGuard.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
exceedsClickThreshold,
|
||||
useClickDragGuard
|
||||
} from '@/composables/useClickDragGuard'
|
||||
|
||||
describe('exceedsClickThreshold', () => {
|
||||
it('returns false when distance is within threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when distance exceeds threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when distance exactly equals threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles negative deltas', () => {
|
||||
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useClickDragGuard', () => {
|
||||
it('reports no drag when pointer has not moved', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no drag when movement is within threshold', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports drag when movement exceeds threshold', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no start has been recorded', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false after reset', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
guard.reset()
|
||||
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const guard = useClickDragGuard(3)
|
||||
guard.recordStart({ clientX: 0, clientY: 0 })
|
||||
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
|
||||
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
41
src/composables/useClickDragGuard.ts
Normal file
41
src/composables/useClickDragGuard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
interface PointerPosition {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
export function exceedsClickThreshold(
|
||||
start: PointerPosition,
|
||||
end: PointerPosition,
|
||||
threshold: number
|
||||
): boolean {
|
||||
return squaredDistance(start, end) > threshold * threshold
|
||||
}
|
||||
|
||||
export function useClickDragGuard(threshold: number = 5) {
|
||||
let start: PointerPosition | null = null
|
||||
|
||||
function recordStart(e: { clientX: number; clientY: number }) {
|
||||
start = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function wasDragged(e: { clientX: number; clientY: number }): boolean {
|
||||
if (!start) return false
|
||||
return exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
threshold
|
||||
)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
start = null
|
||||
}
|
||||
|
||||
return { recordStart, wasDragged, reset }
|
||||
}
|
||||
@@ -107,6 +107,27 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
|
||||
EssentialsCategory
|
||||
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
|
||||
|
||||
/**
|
||||
* Precomputed rank map: category → display order index.
|
||||
* Used for sorting essentials folders in their canonical order.
|
||||
*/
|
||||
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
|
||||
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
|
||||
)
|
||||
|
||||
/**
|
||||
* Precomputed rank maps: category → (node name → display order index).
|
||||
* Used for sorting nodes within each essentials folder.
|
||||
*/
|
||||
export const ESSENTIALS_NODE_RANK: Partial<
|
||||
Record<EssentialsCategory, ReadonlyMap<string, number>>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
|
||||
category,
|
||||
new Map(nodes.map((name, i) => [name, i]))
|
||||
])
|
||||
)
|
||||
|
||||
/**
|
||||
* "Novel" toolkit nodes for telemetry — basics excluded.
|
||||
* Derived from ESSENTIALS_NODES minus the 'basics' category.
|
||||
|
||||
@@ -138,15 +138,18 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.name).toBe('myWidget')
|
||||
})
|
||||
|
||||
test('name uses displayName when provided', () => {
|
||||
test('name uses identityName when provided, label uses displayName', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'1',
|
||||
'myWidget',
|
||||
'Custom Label'
|
||||
'Custom Label',
|
||||
undefined,
|
||||
'my_slot'
|
||||
)
|
||||
expect(view.name).toBe('Custom Label')
|
||||
expect(view.name).toBe('my_slot')
|
||||
expect(view.label).toBe('Custom Label')
|
||||
})
|
||||
|
||||
test('node getter returns the subgraphNode', () => {
|
||||
@@ -334,11 +337,11 @@ describe(createPromotedWidgetView, () => {
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const bareId = String(innerNode.id)
|
||||
|
||||
// No displayName → falls back to widgetName
|
||||
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
|
||||
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
|
||||
expect(view1.label).toBe('myWidget')
|
||||
expect(view1.label).toBeUndefined()
|
||||
|
||||
// With displayName → falls back to displayName
|
||||
// With displayName → label falls back to displayName
|
||||
const view2 = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
bareId,
|
||||
@@ -1012,7 +1015,9 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
// .name stays as identity (subgraph input name), .label updates for display
|
||||
expect(afterRename.name).toBe('seed')
|
||||
expect(afterRename.label).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
|
||||
@@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
@@ -50,14 +56,16 @@ export function createPromotedWidgetView(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
disambiguatingSourceNodeId?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId
|
||||
disambiguatingSourceNodeId,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,12 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
/** Cached reference to the bound subgraph slot, set at construction. */
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
readonly disambiguatingSourceNodeId?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
@@ -100,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.displayName ?? this.sourceWidgetName
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
@@ -188,15 +201,58 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
// Fall back to persisted widget state (survives save/reload before
|
||||
// the slot binding is established) then to construction displayName.
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName ?? this.sourceWidgetName
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
// Also persist to widget state store for save/reload resilience
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached bound subgraph slot reference, refreshing only when
|
||||
* the subgraph node's input list has changed (length mismatch).
|
||||
*
|
||||
* Note: Using length as the cache key works because the returned reference
|
||||
* is the same mutable slot object. When slot properties (label, name) change,
|
||||
* the caller reads fresh values from that reference. The cache only needs
|
||||
* to invalidate when slots are added or removed, which changes length.
|
||||
*/
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
@@ -238,21 +294,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -68,9 +70,7 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
// Context menu tracking
|
||||
private rightMouseDownX: number = 0
|
||||
private rightMouseDownY: number = 0
|
||||
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
@@ -197,18 +197,20 @@ class Load3d {
|
||||
|
||||
const mousedownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
this.rightMouseDownX = e.clientX
|
||||
this.rightMouseDownY = e.clientY
|
||||
this.rightMouseStart = { x: e.clientX, y: e.clientY }
|
||||
this.rightMouseMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
const mousemoveHandler = (e: MouseEvent) => {
|
||||
if (e.buttons === 2) {
|
||||
const dx = Math.abs(e.clientX - this.rightMouseDownX)
|
||||
const dy = Math.abs(e.clientY - this.rightMouseDownY)
|
||||
|
||||
if (dx > this.dragThreshold || dy > this.dragThreshold) {
|
||||
if (
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
) {
|
||||
this.rightMouseMoved = true
|
||||
}
|
||||
}
|
||||
@@ -217,12 +219,13 @@ class Load3d {
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
if (this.isViewerMode) return
|
||||
|
||||
const dx = Math.abs(e.clientX - this.rightMouseDownX)
|
||||
const dy = Math.abs(e.clientY - this.rightMouseDownY)
|
||||
const wasDragging =
|
||||
this.rightMouseMoved ||
|
||||
dx > this.dragThreshold ||
|
||||
dy > this.dragThreshold
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
|
||||
this.rightMouseMoved = false
|
||||
|
||||
|
||||
207
src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts
Normal file
207
src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
getCurrentSource: vi.fn(() => 'test'),
|
||||
getCurrentActor: vi.fn(() => 'test'),
|
||||
applyOperation: vi.fn(),
|
||||
pendingSlotSync: false
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockCtx(): CanvasRenderingContext2D {
|
||||
return createMockCanvas2DContext({
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
closePath: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
createLinearGradient: vi.fn().mockReturnValue({
|
||||
addColorStop: vi.fn()
|
||||
}),
|
||||
bezierCurveTo: vi.fn(),
|
||||
quadraticCurveTo: vi.fn(),
|
||||
isPointInStroke: vi.fn().mockReturnValue(false),
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||
shadowColor: '',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
imageSmoothingEnabled: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a link between two nodes by directly mutating graph state,
|
||||
* bypassing the layout store integration in connect().
|
||||
*/
|
||||
function createTestLink(
|
||||
graph: LGraph,
|
||||
sourceNode: LGraphNode,
|
||||
outputSlot: number,
|
||||
targetNode: LGraphNode,
|
||||
inputSlot: number
|
||||
): LLink {
|
||||
const linkId = ++graph.state.lastLinkId
|
||||
const link = new LLink(
|
||||
linkId,
|
||||
sourceNode.outputs[outputSlot].type,
|
||||
sourceNode.id,
|
||||
outputSlot,
|
||||
targetNode.id,
|
||||
inputSlot
|
||||
)
|
||||
graph._links.set(linkId, link)
|
||||
sourceNode.outputs[outputSlot].links ??= []
|
||||
sourceNode.outputs[outputSlot].links!.push(linkId)
|
||||
targetNode.inputs[inputSlot].link = linkId
|
||||
return link
|
||||
}
|
||||
|
||||
describe('drawConnections widget-input slot positioning', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia())
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true
|
||||
})
|
||||
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
it('arranges widget-input slots before rendering links', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
const widget = targetNode.addWidget('text', 'value', '', null)
|
||||
const input = targetNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(targetNode)
|
||||
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
// Before drawConnections, input.pos should not be set
|
||||
expect(input.pos).toBeUndefined()
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
// After drawConnections, input.pos should be set to the widget row
|
||||
expect(input.pos).toBeDefined()
|
||||
expect(input.pos![1]).toBeGreaterThan(0)
|
||||
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(input.pos![1]).toBe(widget.y + offset)
|
||||
})
|
||||
|
||||
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
targetNode.addWidget('text', 'value', '', null)
|
||||
const input = targetNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(targetNode)
|
||||
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
// Pre-arrange so input.pos is already set
|
||||
targetNode._setConcreteSlots()
|
||||
targetNode.arrange()
|
||||
expect(input.pos).toBeDefined()
|
||||
|
||||
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
expect(arrangeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('positions widget-input slots when display name differs from slot.widget.name', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
|
||||
// Widget has a display name that differs from the slot's widget.name
|
||||
// (simulates a renamed subgraph label)
|
||||
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
|
||||
const input = targetNode.addInput('renamed_label', 'STRING')
|
||||
input.widget = { name: 'original_name' }
|
||||
|
||||
// Bind the widget as the slot's _widget (preferred over name-map lookup)
|
||||
input._widget = widget
|
||||
|
||||
graph.add(targetNode)
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
expect(input.pos).toBeDefined()
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(input.pos![1]).toBe(widget.y + offset)
|
||||
})
|
||||
})
|
||||
@@ -5954,6 +5954,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.globalAlpha = this.editor_alpha
|
||||
// for every node
|
||||
const nodes = graph._nodes
|
||||
|
||||
// Ensure widget-input slot positions are computed before rendering links.
|
||||
// arrange() sets input.pos for widget-backed slots, but is normally called
|
||||
// in drawNode (foreground canvas). drawConnections runs on the background
|
||||
// canvas, which may render before drawNode has executed for this frame.
|
||||
// The dirty flag avoids a per-frame O(N) scan of all inputs.
|
||||
for (const node of nodes) {
|
||||
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
|
||||
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// for every input (we render just inputs because it is easier as every slot can only have one input)
|
||||
const { inputs } = node
|
||||
|
||||
@@ -295,6 +295,12 @@ export class LGraphNode
|
||||
*/
|
||||
freeWidgetSpace?: number
|
||||
|
||||
/**
|
||||
* Set to true when widget-backed input slot positions need recalculation.
|
||||
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
|
||||
*/
|
||||
_widgetSlotsDirty = false
|
||||
|
||||
locked?: boolean
|
||||
|
||||
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
|
||||
@@ -1992,6 +1998,7 @@ export class LGraphNode
|
||||
this.widgets ||= []
|
||||
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
|
||||
this.widgets.push(widget)
|
||||
this._widgetSlotsDirty = true
|
||||
|
||||
// Only register with store if node has a valid ID (is already in a graph).
|
||||
// If the node isn't in a graph yet (id === -1), registration happens
|
||||
@@ -2031,9 +2038,11 @@ export class LGraphNode
|
||||
if (input._widget === widget) {
|
||||
input._widget = undefined
|
||||
input.widget = undefined
|
||||
input.pos = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
this._widgetSlotsDirty = true
|
||||
|
||||
widget.onRemove?.()
|
||||
this.widgets.splice(widgetIndex, 1)
|
||||
@@ -4206,40 +4215,29 @@ export class LGraphNode
|
||||
* Arranges the layout of the node's widget input slots.
|
||||
*/
|
||||
private _arrangeWidgetInputSlots(): void {
|
||||
if (!this.widgets) return
|
||||
if (!this.widgets?.length) return
|
||||
|
||||
const slotByWidgetName = new Map<
|
||||
string,
|
||||
INodeInputSlot & { index: number }
|
||||
>()
|
||||
// Build a name→widget map for fast lookup.
|
||||
const widgetByName = new Map<string, IBaseWidget>()
|
||||
for (const w of this.widgets) widgetByName.set(w.name, w)
|
||||
|
||||
for (const [i, slot] of this.inputs.entries()) {
|
||||
// Set widget-backed slot positions from widget Y coordinates.
|
||||
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
|
||||
// components (NodeSlots filters them out), so they have no DOM-registered
|
||||
// position. input.pos serves as the fallback for getSlotPosition().
|
||||
for (const [i, slot] of this._concreteInputs.entries()) {
|
||||
if (!isWidgetInputSlot(slot)) continue
|
||||
|
||||
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
|
||||
}
|
||||
if (!slotByWidgetName.size) return
|
||||
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
|
||||
// Fall back to name-map lookup for regular nodes without _widget set.
|
||||
// Note: the name-map is ambiguous if two promoted inputs share a label;
|
||||
// _widget avoids this since it is a direct reference.
|
||||
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
|
||||
if (!widget) continue
|
||||
|
||||
// Only set custom pos if not using Vue positioning
|
||||
// Vue positioning calculates widget slot positions dynamically
|
||||
if (!LiteGraph.vueNodesMode) {
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
const actualSlot = this._concreteInputs[slot.index]
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
actualSlot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(actualSlot, slot.index, true)
|
||||
}
|
||||
} else {
|
||||
// For Vue positioning, just measure the slots without setting pos
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
|
||||
}
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(slot, i, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4269,6 +4267,7 @@ export class LGraphNode
|
||||
: 0
|
||||
this._arrangeWidgets(widgetStartY)
|
||||
this._arrangeWidgetInputSlots()
|
||||
this._widgetSlotsDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
|
||||
|
||||
type CreateView<TView> = (entry: ViewManagerEntry) => TView
|
||||
type ViewManagerEntry = PromotedWidgetSource & {
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles promoted widget entries to stable view instances.
|
||||
@@ -15,9 +15,9 @@ export class PromotedWidgetViewManager<TView> {
|
||||
private cachedViews: TView[] | null = null
|
||||
private cachedEntryKeys: string[] | null = null
|
||||
|
||||
reconcile(
|
||||
entries: readonly ViewManagerEntry[],
|
||||
createView: CreateView<TView>
|
||||
reconcile<TEntry extends ViewManagerEntry>(
|
||||
entries: readonly TEntry[],
|
||||
createView: (entry: TEntry) => TView
|
||||
): TView[] {
|
||||
const entryKeys = entries.map((entry) =>
|
||||
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -196,6 +196,258 @@ describe('SubgraphNode Synchronization', () => {
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('newOutput')
|
||||
})
|
||||
|
||||
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', '', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
expect(promotedInput.widget).toBeDefined()
|
||||
|
||||
const originalWidgetName = promotedInput.widget!.name
|
||||
|
||||
// Rename the subgraph input label
|
||||
subgraph.inputs[0].label = 'my_custom_prompt'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[0],
|
||||
index: 0,
|
||||
oldName: 'text',
|
||||
newName: 'my_custom_prompt'
|
||||
})
|
||||
|
||||
// widget.name stays as the internal name — NOT the display label
|
||||
expect(promotedInput.widget!.name).toBe(originalWidgetName)
|
||||
|
||||
// The display label is on input.label (live-read via PromotedWidgetView.label)
|
||||
expect(promotedInput.label).toBe('my_custom_prompt')
|
||||
|
||||
// input.widget.name should still match a widget in node.widgets
|
||||
const matchingWidget = subgraphNode.widgets?.find(
|
||||
(w) => w.name === promotedInput.widget!.name
|
||||
)
|
||||
expect(matchingWidget).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve renamed label through serialize/configure round-trip', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'INT')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'INT')
|
||||
interiorNode.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedWidget = subgraphNode.widgets?.[0]
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
// Rename via the subgraph slot (simulates right-click rename)
|
||||
subgraph.inputs[0].label = 'My Seed'
|
||||
subgraphNode.inputs[0].label = 'My Seed'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[0],
|
||||
index: 0,
|
||||
oldName: 'seed',
|
||||
newName: 'My Seed'
|
||||
})
|
||||
|
||||
// Label should be visible before round-trip
|
||||
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
|
||||
// Serialize and reconfigure (simulates save/reload)
|
||||
const serialized = subgraphNode.serialize()
|
||||
subgraphNode.configure(serialized)
|
||||
|
||||
// Label should survive the round-trip
|
||||
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetAfterRoundTrip).toBeDefined()
|
||||
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode widget name collision on rename', () => {
|
||||
it('should not collapse two inputs when renamed to the same label', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'prompt_a', type: 'STRING' },
|
||||
{ name: 'prompt_b', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
|
||||
// Create two interior nodes with widgets
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
nodeA.addInput('value', 'STRING')
|
||||
nodeA.inputs[0].widget = { name: 'value' }
|
||||
nodeA.addOutput('out', 'STRING')
|
||||
nodeA.addWidget('text', 'value', '', () => {})
|
||||
subgraph.add(nodeA)
|
||||
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
nodeB.addInput('value', 'STRING')
|
||||
nodeB.inputs[0].widget = { name: 'value' }
|
||||
nodeB.addOutput('out', 'STRING')
|
||||
nodeB.addWidget('text', 'value', '', () => {})
|
||||
subgraph.add(nodeB)
|
||||
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs).toHaveLength(2)
|
||||
// widget.name is now nodeId:widgetName (stable composite key)
|
||||
const key0 = subgraphNode.inputs[0].widget?.name
|
||||
const key1 = subgraphNode.inputs[1].widget?.name
|
||||
expect(key0).toBeDefined()
|
||||
expect(key1).toBeDefined()
|
||||
expect(key0).not.toBe(key1)
|
||||
|
||||
// Rename prompt_b to same LABEL as prompt_a
|
||||
subgraph.inputs[1].label = 'prompt_a'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[1],
|
||||
index: 1,
|
||||
oldName: 'prompt_b',
|
||||
newName: 'prompt_a'
|
||||
})
|
||||
|
||||
// Both inputs survive — widget.name stays as composite key, no collision
|
||||
expect(subgraphNode.inputs).toHaveLength(2)
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
|
||||
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
|
||||
|
||||
// Display labels: input[1] was renamed
|
||||
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
|
||||
|
||||
// Distinct _widget bindings
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep unique widget.name keys even with duplicate labels', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: 'INT' },
|
||||
{ name: 'seed2', type: 'INT' }
|
||||
]
|
||||
})
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
nodeA.addInput('value', 'INT')
|
||||
nodeA.inputs[0].widget = { name: 'value' }
|
||||
nodeA.addOutput('out', 'INT')
|
||||
nodeA.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(nodeA)
|
||||
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
nodeB.addInput('value', 'INT')
|
||||
nodeB.inputs[0].widget = { name: 'value' }
|
||||
nodeB.addOutput('out', 'INT')
|
||||
nodeB.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(nodeB)
|
||||
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const key0 = subgraphNode.inputs[0].widget?.name
|
||||
const key1 = subgraphNode.inputs[1].widget?.name
|
||||
|
||||
// Keys should be unique composite identifiers (nodeId:widgetName)
|
||||
expect(key0).toBeDefined()
|
||||
expect(key1).toBeDefined()
|
||||
expect(key0).not.toBe(key1)
|
||||
|
||||
// Rename seed2 to "seed" — duplicate display label
|
||||
subgraph.inputs[1].label = 'seed'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[1],
|
||||
index: 1,
|
||||
oldName: 'seed2',
|
||||
newName: 'seed'
|
||||
})
|
||||
|
||||
// Widget keys remain stable — rename only affects display label
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
|
||||
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
|
||||
|
||||
// Distinct _widget bindings survive the rename
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
)
|
||||
})
|
||||
|
||||
it('should not lose input when onGraphConfigured runs after duplicate rename', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'alpha', type: 'STRING' },
|
||||
{ name: 'beta', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
nodeA.addInput('value', 'STRING')
|
||||
nodeA.inputs[0].widget = { name: 'value' }
|
||||
nodeA.addOutput('out', 'STRING')
|
||||
nodeA.addWidget('text', 'value', '', () => {})
|
||||
subgraph.add(nodeA)
|
||||
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
nodeB.addInput('value', 'STRING')
|
||||
nodeB.inputs[0].widget = { name: 'value' }
|
||||
nodeB.addOutput('out', 'STRING')
|
||||
nodeB.addWidget('text', 'value', '', () => {})
|
||||
subgraph.add(nodeB)
|
||||
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Rename beta to "alpha" — collision
|
||||
subgraph.inputs[1].label = 'alpha'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[1],
|
||||
index: 1,
|
||||
oldName: 'beta',
|
||||
newName: 'alpha'
|
||||
})
|
||||
|
||||
// Simulate onGraphConfigured check: for each input with widget,
|
||||
// find a matching widget by name. If not found, the input gets removed.
|
||||
for (const input of subgraphNode.inputs) {
|
||||
if (!input.widget) continue
|
||||
const name = input.widget.name
|
||||
const w = subgraphNode.widgets?.find((w) => w.name === name)
|
||||
// Every input should find at least one matching widget
|
||||
expect(w).toBeDefined()
|
||||
}
|
||||
|
||||
// Both inputs should survive
|
||||
expect(subgraphNode.inputs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode Lifecycle', () => {
|
||||
|
||||
@@ -63,6 +63,8 @@ workflowSvg.src =
|
||||
type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
/** The subgraph input slot's internal name (stable identity). */
|
||||
slotName: string
|
||||
}
|
||||
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
|
||||
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
|
||||
@@ -192,6 +194,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
sourceNodeId: boundWidget.sourceNodeId,
|
||||
sourceWidgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
@@ -206,6 +209,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
...resolved
|
||||
})
|
||||
}
|
||||
@@ -277,7 +281,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
|
||||
entry.disambiguatingSourceNodeId
|
||||
entry.disambiguatingSourceNodeId,
|
||||
entry.slotName
|
||||
)
|
||||
)
|
||||
|
||||
@@ -333,6 +338,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
sourceWidgetName: string
|
||||
viewKey?: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
slotName?: string
|
||||
}>
|
||||
} {
|
||||
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
|
||||
@@ -562,17 +568,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
slotName: string
|
||||
}> {
|
||||
return linkedEntries.map(
|
||||
({
|
||||
inputKey,
|
||||
inputName,
|
||||
slotName,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
slotName,
|
||||
disambiguatingSourceNodeId,
|
||||
viewKey: this._makePromotionViewKey(
|
||||
inputKey,
|
||||
sourceNodeId,
|
||||
@@ -780,9 +791,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!input) throw new Error('Subgraph input not found')
|
||||
|
||||
input.label = newName
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
// Do NOT change input.widget.name — it is the stable internal
|
||||
// identifier used by onGraphConfigured (widgetInputs.ts) to match
|
||||
// inputs to widgets. Changing it to the display label would cause
|
||||
// collisions when two promoted inputs share the same label.
|
||||
// Display is handled via input.label and _widget.label.
|
||||
if (input._widget) input._widget.label = newName
|
||||
this._invalidatePromotedViewsCache()
|
||||
this.graph?.trigger('node:slot-label:changed', {
|
||||
nodeId: this.id,
|
||||
@@ -1134,6 +1148,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a promoted widget view to a subgraph input slot.
|
||||
*
|
||||
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
|
||||
* promotion store, sets up the prototype chain for multi-level subgraph
|
||||
* nesting, and dispatches the `widget-promoted` event.
|
||||
*/
|
||||
private _setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
@@ -1187,7 +1208,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
})
|
||||
}
|
||||
|
||||
// Create/retrieve the view from cache
|
||||
// Create/retrieve the view from cache.
|
||||
// The cache key uses `input.name` (the slot's internal name) rather
|
||||
// than `subgraphInput.name` because nested subgraphs may remap
|
||||
// the internal name independently of the interior node.
|
||||
const view = this._promotedViewManager.getOrCreate(
|
||||
nodeId,
|
||||
widgetName,
|
||||
@@ -1197,7 +1221,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name,
|
||||
sourceNodeId
|
||||
sourceNodeId,
|
||||
subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
@@ -1211,6 +1236,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
// multiple levels of subgraphs. As part of this, it intentionally avoids
|
||||
// creating new objects. Have care when making changes.
|
||||
// Use subgraphInput.name as the stable identity — unique per subgraph
|
||||
// slot, immune to label renames. Matches PromotedWidgetView.name.
|
||||
// Display is handled via widget.label / PromotedWidgetView.label.
|
||||
input.widget ??= { name: subgraphInput.name }
|
||||
input.widget.name = subgraphInput.name
|
||||
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
|
||||
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
|
||||
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
"promptExecutionError": "فشل تنفيذ الطلب",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"imageLightbox": "Image preview",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
|
||||
"viewGrid": "Grid view",
|
||||
"imageGallery": "image gallery",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"previousImage": "Previous image",
|
||||
@@ -276,8 +278,7 @@
|
||||
"clearAll": "Clear all",
|
||||
"copyURL": "Copy URL",
|
||||
"releaseTitle": "{package} {version} Release",
|
||||
"itemSelected": "{selectedCount} item selected",
|
||||
"itemsSelected": "{selectedCount} items selected",
|
||||
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
|
||||
"multiSelectDropdown": "Multi-select dropdown",
|
||||
"singleSelectDropdown": "Single-select dropdown",
|
||||
"progressCountOf": "of",
|
||||
@@ -1885,7 +1886,9 @@
|
||||
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
|
||||
"noStackTrace": "No stacktrace available",
|
||||
"extensionFileHint": "This may be due to the following script",
|
||||
"promptExecutionError": "Prompt execution failed"
|
||||
"promptExecutionError": "Prompt execution failed",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"title": "Sign In Required to Use API Nodes",
|
||||
@@ -3703,5 +3706,18 @@
|
||||
"footer": "ComfyUI stays free and open source. Cloud is optional.",
|
||||
"continueLocally": "Continue Locally",
|
||||
"exploreCloud": "Try Cloud for Free"
|
||||
},
|
||||
"execution": {
|
||||
"generating": "Generating…",
|
||||
"saving": "Saving…",
|
||||
"loading": "Loading…",
|
||||
"encoding": "Encoding…",
|
||||
"decoding": "Decoding…",
|
||||
"processing": "Processing…",
|
||||
"resizing": "Resizing…",
|
||||
"generatingVideo": "Generating video…",
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "Esto puede deberse al siguiente script",
|
||||
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
|
||||
"noStackTrace": "No hay seguimiento de pila disponible",
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
"promptExecutionError": "La ejecución del prompt falló",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
|
||||
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد دادههای workflow متوقف شد",
|
||||
"noStackTrace": "هیچ stacktraceی موجود نیست",
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطا",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "Cela peut être dû au script suivant",
|
||||
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
|
||||
"noStackTrace": "Aucune trace de pile disponible",
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué"
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
|
||||
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
|
||||
"noStackTrace": "スタックトレースは利用できません",
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました"
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 件のエラー",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
|
||||
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
|
||||
"noStackTrace": "스택 추적을 사용할 수 없습니다",
|
||||
"promptExecutionError": "프롬프트 실행 실패"
|
||||
"promptExecutionError": "프롬프트 실행 실패",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}개 오류",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "Isso pode ser devido ao seguinte script",
|
||||
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
|
||||
"noStackTrace": "Nenhum stacktrace disponível",
|
||||
"promptExecutionError": "Falha na execução do prompt"
|
||||
"promptExecutionError": "Falha na execução do prompt",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRO | {count} ERROS",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "Это может быть связано со следующим скриптом",
|
||||
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
|
||||
"noStackTrace": "Стек вызовов недоступен",
|
||||
"promptExecutionError": "Ошибка выполнения запроса"
|
||||
"promptExecutionError": "Ошибка выполнения запроса",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
|
||||
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
|
||||
"noStackTrace": "Yığın izi mevcut değil",
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu"
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} HATA | {count} HATA",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "這可能是由於以下指令碼所致",
|
||||
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
|
||||
"noStackTrace": "沒有可用的堆疊追蹤",
|
||||
"promptExecutionError": "提示執行失敗"
|
||||
"promptExecutionError": "提示執行失敗",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 個錯誤",
|
||||
|
||||
@@ -888,7 +888,9 @@
|
||||
"extensionFileHint": "这可能是由于以下脚本",
|
||||
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
|
||||
"noStackTrace": "无可用堆栈跟踪",
|
||||
"promptExecutionError": "提示执行失败"
|
||||
"promptExecutionError": "提示执行失败",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}个错误",
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
getCnrIdFromProperties,
|
||||
getCnrIdFromNode
|
||||
} from './missingNodeErrorUtil'
|
||||
import { getCnrIdFromNode, getCnrIdFromProperties } from './cnrIdUtil'
|
||||
|
||||
describe('getCnrIdFromProperties', () => {
|
||||
it('returns cnr_id when present', () => {
|
||||
@@ -20,8 +20,8 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getExecutionIdByNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/utils/missingNodeErrorUtil', () => ({
|
||||
getCnrIdFromNode: vi.fn(() => null)
|
||||
vi.mock('@/platform/nodeReplacement/cnrIdUtil', () => ({
|
||||
getCnrIdFromNode: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -48,11 +48,10 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
|
||||
function mockNode(
|
||||
id: number,
|
||||
@@ -72,7 +71,7 @@ function mockGraph(): LGraph {
|
||||
}
|
||||
|
||||
function getMissingNodesError(
|
||||
store: ReturnType<typeof useExecutionErrorStore>
|
||||
store: ReturnType<typeof useMissingNodesErrorStore>
|
||||
) {
|
||||
const error = store.missingNodesError
|
||||
if (!error) throw new Error('Expected missingNodesError to be defined')
|
||||
@@ -99,7 +98,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
@@ -112,7 +111,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
expect(error.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
@@ -129,7 +128,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
expect(error.nodeTypes).toHaveLength(1)
|
||||
const missing = error.nodeTypes[0]
|
||||
@@ -142,7 +141,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.nodeId).toBe('exec-42')
|
||||
@@ -154,7 +153,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.nodeId).toBe('99')
|
||||
@@ -167,7 +166,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.cnrId).toBe(
|
||||
@@ -194,7 +193,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(true)
|
||||
@@ -209,7 +208,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
|
||||
@@ -225,7 +224,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.type).toBe('OriginalType')
|
||||
|
||||
@@ -2,13 +2,13 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
|
||||
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
|
||||
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
|
||||
@@ -41,5 +41,7 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
|
||||
/** Re-scan the graph for missing nodes and update the error store. */
|
||||
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
|
||||
const types = scanMissingNodes(rootGraph)
|
||||
useExecutionErrorStore().surfaceMissingNodes(types)
|
||||
if (useMissingNodesErrorStore().surfaceMissingNodes(types)) {
|
||||
useExecutionErrorStore().showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
215
src/platform/nodeReplacement/missingNodesErrorStore.test.ts
Normal file
215
src/platform/nodeReplacement/missingNodesErrorStore.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => mockShowErrorsTab.value)
|
||||
}))
|
||||
}))
|
||||
|
||||
import { useMissingNodesErrorStore } from './missingNodesErrorStore'
|
||||
|
||||
describe('missingNodesErrorStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingNodeTypes', () => {
|
||||
it('sets missingNodesError with provided types', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
const types: MissingNodeType[] = [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
]
|
||||
store.setMissingNodeTypes(types)
|
||||
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingNodesError when given empty array', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
|
||||
store.setMissingNodeTypes([])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates string entries by value', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'NodeA',
|
||||
'NodeA',
|
||||
'NodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by nodeId when present', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by type when nodeId is absent', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', isReplaceable: false },
|
||||
{ type: 'NodeA', isReplaceable: true }
|
||||
] as MissingNodeType[])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps distinct nodeIds even when type is the same', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingNodes', () => {
|
||||
beforeEach(() => {
|
||||
mockShowErrorsTab.value = false
|
||||
})
|
||||
|
||||
it('stores missing node types and returns false when setting disabled', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
const types: MissingNodeType[] = [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
]
|
||||
const shouldShowOverlay = store.surfaceMissingNodes(types)
|
||||
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
expect(shouldShowOverlay).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when ShowErrorsTab setting is enabled', () => {
|
||||
mockShowErrorsTab.value = true
|
||||
const store = useMissingNodesErrorStore()
|
||||
const shouldShowOverlay = store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(shouldShowOverlay).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when ShowErrorsTab setting is disabled', () => {
|
||||
mockShowErrorsTab.value = false
|
||||
const store = useMissingNodesErrorStore()
|
||||
const shouldShowOverlay = store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(shouldShowOverlay).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty types even when setting is enabled', () => {
|
||||
mockShowErrorsTab.value = true
|
||||
const store = useMissingNodesErrorStore()
|
||||
const shouldShowOverlay = store.surfaceMissingNodes([])
|
||||
|
||||
expect(shouldShowOverlay).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates node types', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingNodesByType', () => {
|
||||
it('removes matching types from the missing nodes list', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA', 'NodeC'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
const remaining = store.missingNodesError?.nodeTypes[0]
|
||||
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
|
||||
})
|
||||
|
||||
it('clears missingNodesError when all types are removed', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when missingNodesError is null', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when removing non-existent types', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NonExistent'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles removing from string entries', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'StringNodeA',
|
||||
'StringNodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
store.removeMissingNodesByType(['StringNodeA'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
125
src/platform/nodeReplacement/missingNodesErrorStore.ts
Normal file
125
src/platform/nodeReplacement/missingNodesErrorStore.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface MissingNodesError {
|
||||
message: string
|
||||
nodeTypes: MissingNodeType[]
|
||||
}
|
||||
|
||||
export const useMissingNodesErrorStore = defineStore(
|
||||
'missingNodesError',
|
||||
() => {
|
||||
const missingNodesError = ref<MissingNodesError | null>(null)
|
||||
|
||||
function setMissingNodeTypes(types: MissingNodeType[]) {
|
||||
if (!types.length) {
|
||||
missingNodesError.value = null
|
||||
return
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const uniqueTypes = types.filter((node) => {
|
||||
// For string entries (group nodes), deduplicate by the string itself.
|
||||
// For object entries, prefer nodeId so multiple instances of the same
|
||||
// type are kept as separate rows; fall back to type if nodeId is absent.
|
||||
const isString = typeof node === 'string'
|
||||
let key: string
|
||||
if (isString) {
|
||||
key = node
|
||||
} else if (node.nodeId != null) {
|
||||
key = String(node.nodeId)
|
||||
} else {
|
||||
key = node.type
|
||||
}
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
missingNodesError.value = {
|
||||
message: isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
nodeTypes: uniqueTypes
|
||||
}
|
||||
}
|
||||
|
||||
/** Set missing node types. Returns true if the Errors tab is enabled and types were set. */
|
||||
function surfaceMissingNodes(types: MissingNodeType[]): boolean {
|
||||
setMissingNodeTypes(types)
|
||||
return (
|
||||
types.length > 0 &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
}
|
||||
|
||||
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
|
||||
function removeMissingNodesByType(typesToRemove: string[]) {
|
||||
if (!missingNodesError.value) return
|
||||
const removeSet = new Set(typesToRemove)
|
||||
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
|
||||
const nodeType = typeof node === 'string' ? node : node.type
|
||||
return !removeSet.has(nodeType)
|
||||
})
|
||||
setMissingNodeTypes(remaining)
|
||||
}
|
||||
|
||||
const hasMissingNodes = computed(() => !!missingNodesError.value)
|
||||
|
||||
const missingNodeCount = computed(
|
||||
() => missingNodesError.value?.nodeTypes.length ?? 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing node execution IDs,
|
||||
* including the missing nodes themselves.
|
||||
*
|
||||
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
const error = missingNodesError.value
|
||||
if (!error) return ids
|
||||
|
||||
for (const nodeType of error.nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
/** True if the node has a missing node inside it at any nesting depth. */
|
||||
function isContainerWithMissingNode(node: LGraphNode): boolean {
|
||||
if (!app.isGraphReady) return false
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return false
|
||||
return missingAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
return {
|
||||
missingNodesError,
|
||||
setMissingNodeTypes,
|
||||
surfaceMissingNodes,
|
||||
removeMissingNodesByType,
|
||||
hasMissingNodes,
|
||||
missingNodeCount,
|
||||
missingAncestorExecutionIds,
|
||||
isContainerWithMissingNode
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -47,8 +47,8 @@ vi.mock('@/i18n', () => ({
|
||||
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
|
||||
mockRemoveMissingNodesByType: vi.fn()
|
||||
}))
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: vi.fn(() => ({
|
||||
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
|
||||
useMissingNodesErrorStore: vi.fn(() => ({
|
||||
removeMissingNodesByType: mockRemoveMissingNodesByType
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app, sanitizeNodeName } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
@@ -329,24 +329,24 @@ export function useNodeReplacement() {
|
||||
|
||||
/**
|
||||
* Replaces all nodes in a single swap group and removes successfully
|
||||
* replaced types from the execution error store.
|
||||
* replaced types from the missing nodes error store.
|
||||
*/
|
||||
function replaceGroup(group: ReplacementGroup): void {
|
||||
const replaced = replaceNodesInPlace(group.nodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
useExecutionErrorStore().removeMissingNodesByType(replaced)
|
||||
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces every available node across all swap groups and removes
|
||||
* the succeeded types from the execution error store.
|
||||
* the succeeded types from the missing nodes error store.
|
||||
*/
|
||||
function replaceAllGroups(groups: ReplacementGroup[]): void {
|
||||
const allNodeTypes = groups.flatMap((g) => g.nodeTypes)
|
||||
const replaced = replaceNodesInPlace(allNodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
useExecutionErrorStore().removeMissingNodesByType(replaced)
|
||||
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -12,6 +12,7 @@
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,13 +25,15 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ MixpanelTelemetryProvider },
|
||||
{ GtmTelemetryProvider },
|
||||
{ ImpactTelemetryProvider },
|
||||
{ PostHogTelemetryProvider }
|
||||
{ PostHogTelemetryProvider },
|
||||
{ ClickHouseTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider'),
|
||||
import('./providers/cloud/ImpactTelemetryProvider'),
|
||||
import('./providers/cloud/PostHogTelemetryProvider')
|
||||
import('./providers/cloud/PostHogTelemetryProvider'),
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
@@ -39,6 +41,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
registry.registerProvider(new GtmTelemetryProvider())
|
||||
registry.registerProvider(new ImpactTelemetryProvider())
|
||||
registry.registerProvider(new PostHogTelemetryProvider())
|
||||
registry.registerProvider(new ClickHouseTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { TelemetryProvider, WorkflowImportMetadata } from '../../types'
|
||||
|
||||
/**
|
||||
* ClickHouse Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
* Sends observability events to the cloud backend's ClickHouse pipeline
|
||||
* via POST /api/internal/cloud_analytics. Currently tracks missing node
|
||||
* data when users open/import workflows with unsupported nodes.
|
||||
*
|
||||
* This provider is separate from Mixpanel because ClickHouse is the
|
||||
* canonical store for post-hoc analytics (per observability philosophy).
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This file is tree-shaken away in OSS builds (DISTRIBUTION unset).
|
||||
*/
|
||||
export class ClickHouseTelemetryProvider implements TelemetryProvider {
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.reportMissingNodes(metadata)
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.reportMissingNodes(metadata)
|
||||
}
|
||||
|
||||
private reportMissingNodes(metadata: WorkflowImportMetadata): void {
|
||||
if (metadata.missing_node_count <= 0) return
|
||||
|
||||
api
|
||||
.fetchApi('/internal/cloud_analytics', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_name: 'node_missing',
|
||||
event_data: {
|
||||
missing_class_types: metadata.missing_node_types,
|
||||
missing_count: metadata.missing_node_count,
|
||||
source: metadata.open_source ?? 'unknown'
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
@@ -160,7 +160,7 @@ describe('useWorkflowService', () => {
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -170,9 +170,9 @@ describe('useWorkflowService', () => {
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
|
||||
missingNodeTypes
|
||||
)
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(missingNodeTypes)
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
@@ -185,9 +185,9 @@ describe('useWorkflowService', () => {
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
|
||||
['CustomNode1']
|
||||
)
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('useWorkflowService', () => {
|
||||
service.showPendingWarnings(workflow)
|
||||
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -226,7 +226,7 @@ describe('useWorkflowService', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).not.toHaveBeenCalled()
|
||||
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
@@ -238,9 +238,9 @@ describe('useWorkflowService', () => {
|
||||
workflow,
|
||||
expect.objectContaining({ deferWarnings: true })
|
||||
)
|
||||
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
|
||||
['CustomNode1']
|
||||
)
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
@@ -258,20 +258,20 @@ describe('useWorkflowService', () => {
|
||||
|
||||
await service.openWorkflow(workflow1)
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
|
||||
['MissingNodeA']
|
||||
)
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['MissingNodeA'])
|
||||
expect(workflow1.pendingWarnings).toBeNull()
|
||||
expect(workflow2.pendingWarnings).not.toBeNull()
|
||||
|
||||
await service.openWorkflow(workflow2)
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenLastCalledWith(['MissingNodeB'])
|
||||
expect(workflow2.pendingWarnings).toBeNull()
|
||||
})
|
||||
@@ -286,12 +286,12 @@ describe('useWorkflowService', () => {
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(
|
||||
useExecutionErrorStore().surfaceMissingNodes
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
appendJsonExt,
|
||||
@@ -43,6 +44,7 @@ export const useWorkflowService = () => {
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
|
||||
function confirmOverwrite(targetPath: string) {
|
||||
@@ -542,7 +544,9 @@ export const useWorkflowService = () => {
|
||||
wf.pendingWarnings = null
|
||||
|
||||
if (missingNodeTypes?.length) {
|
||||
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
|
||||
if (missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes)) {
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -250,61 +250,125 @@ function readSessionPointer<T extends { workspaceId: string }>(
|
||||
|
||||
/**
|
||||
* Reads the active path pointer from sessionStorage.
|
||||
* Falls back to workspace-based search when clientId changes after reload.
|
||||
* Falls back to workspace-based search when clientId changes after reload,
|
||||
* then to localStorage when sessionStorage is empty (browser restart).
|
||||
*/
|
||||
export function readActivePath(
|
||||
clientId: string,
|
||||
targetWorkspaceId?: string
|
||||
): ActivePathPointer | null {
|
||||
return readSessionPointer<ActivePathPointer>(
|
||||
StorageKeys.activePath(clientId),
|
||||
StorageKeys.prefixes.activePath,
|
||||
targetWorkspaceId
|
||||
return (
|
||||
readSessionPointer<ActivePathPointer>(
|
||||
StorageKeys.activePath(clientId),
|
||||
StorageKeys.prefixes.activePath,
|
||||
targetWorkspaceId
|
||||
) ??
|
||||
(targetWorkspaceId
|
||||
? readLocalPointer<ActivePathPointer>(
|
||||
StorageKeys.lastActivePath(targetWorkspaceId),
|
||||
isValidActivePathPointer
|
||||
)
|
||||
: null)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the active path pointer to sessionStorage.
|
||||
* Writes the active path pointer to both sessionStorage (tab-scoped)
|
||||
* and localStorage (survives browser restart).
|
||||
*/
|
||||
export function writeActivePath(
|
||||
clientId: string,
|
||||
pointer: ActivePathPointer
|
||||
): void {
|
||||
try {
|
||||
const key = StorageKeys.activePath(clientId)
|
||||
sessionStorage.setItem(key, JSON.stringify(pointer))
|
||||
} catch {
|
||||
// Best effort - ignore errors
|
||||
}
|
||||
const json = JSON.stringify(pointer)
|
||||
writeStorage(sessionStorage, StorageKeys.activePath(clientId), json)
|
||||
writeStorage(
|
||||
localStorage,
|
||||
StorageKeys.lastActivePath(pointer.workspaceId),
|
||||
json
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the open paths pointer from sessionStorage.
|
||||
* Falls back to workspace-based search when clientId changes after reload.
|
||||
* Falls back to workspace-based search when clientId changes after reload,
|
||||
* then to localStorage when sessionStorage is empty (browser restart).
|
||||
*/
|
||||
export function readOpenPaths(
|
||||
clientId: string,
|
||||
targetWorkspaceId?: string
|
||||
): OpenPathsPointer | null {
|
||||
return readSessionPointer<OpenPathsPointer>(
|
||||
StorageKeys.openPaths(clientId),
|
||||
StorageKeys.prefixes.openPaths,
|
||||
targetWorkspaceId
|
||||
return (
|
||||
readSessionPointer<OpenPathsPointer>(
|
||||
StorageKeys.openPaths(clientId),
|
||||
StorageKeys.prefixes.openPaths,
|
||||
targetWorkspaceId
|
||||
) ??
|
||||
(targetWorkspaceId
|
||||
? readLocalPointer<OpenPathsPointer>(
|
||||
StorageKeys.lastOpenPaths(targetWorkspaceId),
|
||||
isValidOpenPathsPointer
|
||||
)
|
||||
: null)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the open paths pointer to sessionStorage.
|
||||
* Writes the open paths pointer to both sessionStorage (tab-scoped)
|
||||
* and localStorage (survives browser restart).
|
||||
*/
|
||||
export function writeOpenPaths(
|
||||
clientId: string,
|
||||
pointer: OpenPathsPointer
|
||||
): void {
|
||||
const json = JSON.stringify(pointer)
|
||||
writeStorage(sessionStorage, StorageKeys.openPaths(clientId), json)
|
||||
writeStorage(
|
||||
localStorage,
|
||||
StorageKeys.lastOpenPaths(pointer.workspaceId),
|
||||
json
|
||||
)
|
||||
}
|
||||
|
||||
function hasWorkspaceId(obj: Record<string, unknown>): boolean {
|
||||
return typeof obj.workspaceId === 'string'
|
||||
}
|
||||
|
||||
function isValidActivePathPointer(value: unknown): value is ActivePathPointer {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const obj = value as Record<string, unknown>
|
||||
return hasWorkspaceId(obj) && typeof obj.path === 'string'
|
||||
}
|
||||
|
||||
function isValidOpenPathsPointer(value: unknown): value is OpenPathsPointer {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const obj = value as Record<string, unknown>
|
||||
return (
|
||||
hasWorkspaceId(obj) &&
|
||||
Array.isArray(obj.paths) &&
|
||||
typeof obj.activeIndex === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function readLocalPointer<T>(
|
||||
key: string,
|
||||
validate: (value: unknown) => value is T
|
||||
): T | null {
|
||||
try {
|
||||
const key = StorageKeys.openPaths(clientId)
|
||||
sessionStorage.setItem(key, JSON.stringify(pointer))
|
||||
const json = localStorage.getItem(key)
|
||||
if (!json) return null
|
||||
const parsed = JSON.parse(json)
|
||||
return validate(parsed) ? parsed : null
|
||||
} catch {
|
||||
// Best effort - ignore errors
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(storage: Storage, key: string, value: string): void {
|
||||
try {
|
||||
storage.setItem(key, value)
|
||||
} catch {
|
||||
// Best effort — silently degrade when storage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +381,9 @@ export function clearAllV2Storage(): void {
|
||||
|
||||
const prefixes = [
|
||||
StorageKeys.prefixes.draftIndex,
|
||||
StorageKeys.prefixes.draftPayload
|
||||
StorageKeys.prefixes.draftPayload,
|
||||
StorageKeys.prefixes.lastActivePath,
|
||||
StorageKeys.prefixes.lastOpenPaths
|
||||
]
|
||||
|
||||
try {
|
||||
|
||||
@@ -72,6 +72,19 @@ export const StorageKeys = {
|
||||
return `Comfy.Workflow.OpenPaths:${clientId}`
|
||||
},
|
||||
|
||||
/**
|
||||
* localStorage copies of tab pointers for cross-session restore.
|
||||
* sessionStorage is per-tab (correct for in-session use) but lost
|
||||
* on browser restart; these keys preserve the last-written state.
|
||||
*/
|
||||
lastActivePath(workspaceId: string): string {
|
||||
return `Comfy.Workflow.LastActivePath:${workspaceId}`
|
||||
},
|
||||
|
||||
lastOpenPaths(workspaceId: string): string {
|
||||
return `Comfy.Workflow.LastOpenPaths:${workspaceId}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Prefix patterns for cleanup operations.
|
||||
*/
|
||||
@@ -79,6 +92,8 @@ export const StorageKeys = {
|
||||
draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
|
||||
draftPayload: 'Comfy.Workflow.Draft.v2:',
|
||||
activePath: 'Comfy.Workflow.ActivePath:',
|
||||
openPaths: 'Comfy.Workflow.OpenPaths:'
|
||||
openPaths: 'Comfy.Workflow.OpenPaths:',
|
||||
lastActivePath: 'Comfy.Workflow.LastActivePath:',
|
||||
lastOpenPaths: 'Comfy.Workflow.LastOpenPaths:'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ImageLightbox from '@/components/common/ImageLightbox.vue'
|
||||
import { useClickDragGuard } from '@/composables/useClickDragGuard'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
@@ -30,26 +31,17 @@ const {
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const canAcceptDrop = ref(false)
|
||||
const pointerStart = ref<{ x: number; y: number } | null>(null)
|
||||
const clickGuard = useClickDragGuard(5)
|
||||
const lightboxOpen = ref(false)
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointerStart.value = { x: e.clientX, y: e.clientY }
|
||||
clickGuard.recordStart(e)
|
||||
}
|
||||
|
||||
function onIndicatorClick(e: MouseEvent) {
|
||||
if (e.detail !== 0) {
|
||||
const start = pointerStart.value
|
||||
if (start) {
|
||||
const dx = e.clientX - start.x
|
||||
const dy = e.clientY - start.y
|
||||
if (dx * dx + dy * dy > 25) {
|
||||
pointerStart.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
pointerStart.value = null
|
||||
const dragged = e.detail !== 0 && clickGuard.wasDragged(e)
|
||||
clickGuard.reset()
|
||||
if (dragged) return
|
||||
dropIndicator?.onClick?.(e)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,29 @@
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { src } = defineProps<{
|
||||
const { src, showSize = true } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
label?: string
|
||||
showSize?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value || !showSize) return
|
||||
width.value = imageRef.value.naturalWidth
|
||||
height.value = imageRef.value.naturalHeight
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane
|
||||
@@ -27,13 +37,7 @@ const height = ref('')
|
||||
:src
|
||||
v-bind="slotProps"
|
||||
class="size-full object-contain"
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</ZoomPane>
|
||||
<img
|
||||
@@ -41,15 +45,15 @@ const height = ref('')
|
||||
ref="imageRef"
|
||||
class="grow object-contain contain-size"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<span class="self-center md:z-10">
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse self-center text-muted md:z-10"
|
||||
>
|
||||
{{ executionStatusMessage }}
|
||||
</span>
|
||||
<span v-else-if="width && height" class="self-center md:z-10">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div
|
||||
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
|
||||
/>
|
||||
<div
|
||||
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse text-sm text-muted"
|
||||
>
|
||||
{{ executionStatusMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -136,6 +136,7 @@ async function rerun(e: Event) {
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
:show-size="false"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { getExecutionStatusMessage } from './getExecutionStatusMessage'
|
||||
|
||||
// Pass-through t so we can assert the i18n key
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('getExecutionStatusMessage', () => {
|
||||
describe('custom messages', () => {
|
||||
it('returns custom message from properties when set', () => {
|
||||
expect(
|
||||
getExecutionStatusMessage(t, 'KSampler', null, {
|
||||
'Execution Message': 'custom status'
|
||||
})
|
||||
).toBe('custom status')
|
||||
})
|
||||
|
||||
it('ignores empty or whitespace-only custom message', () => {
|
||||
expect(
|
||||
getExecutionStatusMessage(t, 'KSampler', null, {
|
||||
'Execution Message': ' '
|
||||
})
|
||||
).toBe('execution.generating')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API nodes', () => {
|
||||
it('returns processing for API nodes', () => {
|
||||
const apiDef = { api_node: true } as ComfyNodeDefImpl
|
||||
expect(getExecutionStatusMessage(t, 'SomeApiNode', apiDef)).toBe(
|
||||
'execution.processing'
|
||||
)
|
||||
})
|
||||
|
||||
it('statusMap takes precedence over api_node flag', () => {
|
||||
const apiDef = { api_node: true } as ComfyNodeDefImpl
|
||||
expect(getExecutionStatusMessage(t, 'KSampler', apiDef)).toBe(
|
||||
'execution.generating'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node type matching', () => {
|
||||
it('does not match partial PascalCase words', () => {
|
||||
expect(getExecutionStatusMessage(t, 'Loads')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches identifier mid-string at PascalCase boundary', () => {
|
||||
expect(getExecutionStatusMessage(t, 'CompositeSaveImage')).toBe(
|
||||
'execution.saving'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches identifier followed by non-letter characters', () => {
|
||||
expect(getExecutionStatusMessage(t, 'Save_V2')).toBe('execution.saving')
|
||||
expect(getExecutionStatusMessage(t, 'LoadImage🐍')).toBe(
|
||||
'execution.loading'
|
||||
)
|
||||
})
|
||||
|
||||
const testNodeTypes: [string, string[]][] = [
|
||||
['generating', ['KSampler', 'SamplerCustomAdvanced']],
|
||||
[
|
||||
'saving',
|
||||
['SaveImage', 'SaveAnimatedWEBP', 'PreviewImage', 'MaskPreview']
|
||||
],
|
||||
['loading', ['LoadImage', 'VAELoader', 'CheckpointLoaderSimple']],
|
||||
[
|
||||
'encoding',
|
||||
['VAEEncode', 'StableCascade_StageC_VAEEncode', 'CLIPTextEncode']
|
||||
],
|
||||
['decoding', ['VAEDecode', 'VAEDecodeHunyuan3D']],
|
||||
[
|
||||
'resizing',
|
||||
['ImageUpscaleWithModel', 'LatentUpscale', 'ResizeImageMaskNode']
|
||||
],
|
||||
[
|
||||
'processing',
|
||||
['TorchCompileModel', 'SVD_img2vid_Conditioning', 'ModelMergeSimple']
|
||||
],
|
||||
['generatingVideo', ['WanImageToVideo', 'WanFunControlToVideo']],
|
||||
['processingVideo', ['Video Slice', 'CreateVideo']],
|
||||
['training', ['TrainLoraNode']]
|
||||
]
|
||||
|
||||
it.for(
|
||||
testNodeTypes.flatMap(([status, nodes]) =>
|
||||
nodes.map((node) => [status, node] as const)
|
||||
)
|
||||
)('%s ← %s', ([status, nodeType]) => {
|
||||
expect(getExecutionStatusMessage(t, nodeType)).toBe(`execution.${status}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for nodes matching no pattern', () => {
|
||||
expect(getExecutionStatusMessage(t, 'PrimitiveString')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type ExecutionStatusKey =
|
||||
| 'generating'
|
||||
| 'saving'
|
||||
| 'loading'
|
||||
| 'encoding'
|
||||
| 'decoding'
|
||||
| 'processing'
|
||||
| 'resizing'
|
||||
| 'generatingVideo'
|
||||
| 'processingVideo'
|
||||
| 'training'
|
||||
|
||||
/**
|
||||
* Specific status messages for nodes that can't be matched by PascalCase
|
||||
* identifier patterns (e.g. unconventional naming, spaces).
|
||||
*/
|
||||
const statusMap: Record<string, ExecutionStatusKey> = {
|
||||
// Video utility nodes with non-standard naming
|
||||
'Video Slice': 'processingVideo',
|
||||
GetVideoComponents: 'processingVideo',
|
||||
CreateVideo: 'processingVideo',
|
||||
|
||||
// Training
|
||||
TrainLoraNode: 'training'
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a PascalCase identifier within a node type name.
|
||||
*/
|
||||
function pascalId(...ids: string[]): RegExp {
|
||||
return new RegExp('(?:' + ids.join('|') + ')(?![a-z])')
|
||||
}
|
||||
|
||||
const identifierRules: [RegExp, ExecutionStatusKey][] = [
|
||||
[pascalId('Save', 'Preview'), 'saving'],
|
||||
[pascalId('Load', 'Loader'), 'loading'],
|
||||
[pascalId('Encode'), 'encoding'],
|
||||
[pascalId('Decode'), 'decoding'],
|
||||
[pascalId('Compile', 'Conditioning', 'Merge'), 'processing'],
|
||||
[pascalId('Upscale', 'Resize'), 'resizing'],
|
||||
[pascalId('ToVideo'), 'generatingVideo'],
|
||||
[pascalId('Sampler'), 'generating']
|
||||
]
|
||||
|
||||
export function getExecutionStatusMessage(
|
||||
t: (key: string) => string,
|
||||
nodeType: string,
|
||||
nodeDef?: ComfyNodeDefImpl | null,
|
||||
properties?: Record<string, unknown>
|
||||
): string | null {
|
||||
const customMessage = properties?.['Execution Message']
|
||||
if (typeof customMessage === 'string' && customMessage.trim()) {
|
||||
return customMessage.trim()
|
||||
}
|
||||
|
||||
if (nodeType in statusMap) return t(`execution.${statusMap[nodeType]}`)
|
||||
|
||||
for (const [pattern, key] of identifierRules) {
|
||||
if (pattern.test(nodeType)) return t(`execution.${key}`)
|
||||
}
|
||||
|
||||
if (nodeDef?.api_node) return t('execution.processing')
|
||||
|
||||
return null
|
||||
}
|
||||
41
src/renderer/extensions/linearMode/useExecutionStatus.ts
Normal file
41
src/renderer/extensions/linearMode/useExecutionStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getExecutionStatusMessage } from '@/renderer/extensions/linearMode/getExecutionStatusMessage'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolveStatus(
|
||||
t: (key: string) => string,
|
||||
nodeDefStore: ReturnType<typeof useNodeDefStore>,
|
||||
executionId: string | number
|
||||
): string | null {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) return null
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
const nodeType = node?.type
|
||||
if (!nodeType) return null
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType] ?? null
|
||||
return getExecutionStatusMessage(t, nodeType, nodeDef, node.properties)
|
||||
}
|
||||
|
||||
export function useExecutionStatus() {
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const executionStatusMessage = computed<string | null>(() => {
|
||||
const executionId = executionStore.executingNodeId
|
||||
if (!executionId) return null
|
||||
return resolveStatus(t, nodeDefStore, executionId) || t('execution.running')
|
||||
})
|
||||
|
||||
return { executionStatusMessage }
|
||||
}
|
||||
@@ -101,7 +101,11 @@
|
||||
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-muted-foreground">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
<span
|
||||
v-if="videoError"
|
||||
class="text-red-400"
|
||||
data-testid="error-loading-video"
|
||||
>
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="showLoader" class="text-smoke-400">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -31,7 +31,9 @@ const i18n = createI18n({
|
||||
imageFailedToLoad: 'Image failed to load',
|
||||
imageDoesNotExist: 'Image does not exist',
|
||||
unknownFile: 'Unknown file',
|
||||
loading: 'Loading'
|
||||
loading: 'Loading',
|
||||
viewGrid: 'Grid view',
|
||||
galleryThumbnail: 'Gallery thumbnail'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +71,17 @@ describe('ImagePreview', () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** Switch a multi-image wrapper from default grid mode to gallery mode */
|
||||
async function switchToGallery(wrapper: VueWrapper) {
|
||||
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
await thumbnails[0].trigger('click')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapperRegistry.forEach((wrapper) => {
|
||||
wrapper.unmount()
|
||||
@@ -76,30 +89,23 @@ describe('ImagePreview', () => {
|
||||
wrapperRegistry.clear()
|
||||
})
|
||||
|
||||
it('renders image preview when imageUrls provided', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render when no imageUrls provided', () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays calculating dimensions text initially', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
it('displays calculating dimensions text in gallery mode', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
it('shows navigation dots for multiple images in gallery mode', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
@@ -114,113 +120,23 @@ describe('ImagePreview', () => {
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows action buttons on hover', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially buttons should not be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
|
||||
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
await imageWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
// For multiple images: download and remove buttons (no mask button)
|
||||
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides action buttons when not hovering', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
|
||||
// Trigger hover
|
||||
await imageWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
|
||||
// Trigger mouse leave
|
||||
await imageWrapper.trigger('mouseleave')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows action buttons on focus', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially buttons should not be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
|
||||
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
await imageWrapper.trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides action buttons on blur', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
|
||||
// Trigger focus
|
||||
await imageWrapper.trigger('focusin')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
|
||||
// Trigger focusout
|
||||
await imageWrapper.trigger('focusout')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows mask/edit button only for single images', async () => {
|
||||
// Multiple images - should not show mask button
|
||||
// Multiple images in gallery mode - should not show mask button
|
||||
const multipleImagesWrapper = mountImagePreview()
|
||||
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
await switchToGallery(multipleImagesWrapper)
|
||||
|
||||
const maskButtonMultiple = multipleImagesWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonMultiple.exists()).toBe(false)
|
||||
expect(
|
||||
multipleImagesWrapper.find('[aria-label="Edit or mask image"]').exists()
|
||||
).toBe(false)
|
||||
|
||||
// Single image - should show mask button
|
||||
const singleImageWrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const maskButtonSingle = singleImageWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonSingle.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles action button clicks', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Edit/Mask button - just verify it can be clicked without errors
|
||||
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
|
||||
expect(editButton.exists()).toBe(true)
|
||||
await editButton.trigger('click')
|
||||
|
||||
// Test Remove button - just verify it can be clicked without errors
|
||||
const removeButton = wrapper.find('[aria-label="Remove image"]')
|
||||
expect(removeButton.exists()).toBe(true)
|
||||
await removeButton.trigger('click')
|
||||
expect(
|
||||
singleImageWrapper.find('[aria-label="Edit or mask image"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('handles download button click', async () => {
|
||||
@@ -228,20 +144,16 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Download button
|
||||
const downloadButton = wrapper.find('[aria-label="Download image"]')
|
||||
expect(downloadButton.exists()).toBe(true)
|
||||
await downloadButton.trigger('click')
|
||||
|
||||
// Verify the mocked downloadFile was called
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Initially shows first image
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
@@ -253,14 +165,14 @@ describe('ImagePreview', () => {
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Now should show second image
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
|
||||
@@ -268,7 +180,6 @@ describe('ImagePreview', () => {
|
||||
expect(navigationDots[0].attributes('aria-current')).toBe('true')
|
||||
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
|
||||
|
||||
// Switch to second image
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -277,38 +188,224 @@ describe('ImagePreview', () => {
|
||||
expect(navigationDots[1].attributes('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Just verify the image element is properly set up
|
||||
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('alt')).toBe('Node output 1')
|
||||
expect(img.attributes('alt')).toBe('View image 1 of 1')
|
||||
})
|
||||
|
||||
it('updates alt text when switching images', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Initially first image
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('View image 1 of 2')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Alt text should update
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('View image 2 of 2')
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next image with ArrowRight', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to previous image with ArrowLeft', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps around from last to first with ArrowRight', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps around from first to last with ArrowLeft', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to first image with Home', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('.image-preview').trigger('keydown', { key: 'Home' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to last image with End', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper.find('.image-preview').trigger('keydown', { key: 'End' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores arrow keys in grid mode', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores arrow keys for single image', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const initialSrc = wrapper.find('img').attributes('src')
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('img').attributes('src')).toBe(initialSrc)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid view', () => {
|
||||
it('defaults to grid mode for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('defaults to gallery mode for single image', () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(true)
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('switches to gallery mode when grid thumbnail is clicked', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
await thumbnails[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.find('[data-testid="main-image"]')
|
||||
expect(mainImg.exists()).toBe(true)
|
||||
expect(mainImg.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('shows back-to-grid button next to navigation dots', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const gridButton = wrapper.find('[aria-label="Grid view"]')
|
||||
expect(gridButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches back to grid mode via back-to-grid button', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const gridButton = wrapper.find('[aria-label="Grid view"]')
|
||||
await gridButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resets to grid mode when URLs change to multiple images', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Verify we're in gallery mode
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(true)
|
||||
|
||||
// Change URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: [
|
||||
'/api/view?filename=new1.png&type=output',
|
||||
'/api/view?filename=new2.png&type=output',
|
||||
'/api/view?filename=new3.png&type=output'
|
||||
]
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Should be back in grid mode
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch cycling with identical URLs', () => {
|
||||
@@ -319,6 +416,7 @@ describe('ImagePreview', () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [sameUrl, sameUrl, sameUrl]
|
||||
})
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Simulate initial image load
|
||||
await wrapper.find('img').trigger('load')
|
||||
@@ -365,8 +463,7 @@ describe('ImagePreview', () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset - aria-busy should still be false
|
||||
// because the URLs are identical (just a new array reference)
|
||||
// Loading state should NOT have been reset
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -406,16 +503,13 @@ describe('ImagePreview', () => {
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
// No preview initially
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
|
||||
// Add URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Preview should appear
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
@@ -4,18 +4,45 @@
|
||||
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
v-if="viewMode === 'grid'"
|
||||
data-testid="image-grid"
|
||||
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
|
||||
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
|
||||
>
|
||||
<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"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@pointerdown="trackPointerStart"
|
||||
@click="handleGridThumbnailClick($event, index)"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
|
||||
draggable="false"
|
||||
class="pointer-events-none size-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View (Image Wrapper) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
ref="galleryPanelEl"
|
||||
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
role="region"
|
||||
:aria-roledescription="$t('g.imageGallery')"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
:aria-busy="showLoader"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focusin="handleFocusIn"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
@@ -38,22 +65,18 @@
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
data-testid="main-image"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
|
||||
(isHovered || isFocused) && 'opacity-60'
|
||||
)
|
||||
"
|
||||
draggable="false"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-1"
|
||||
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
@@ -76,21 +99,29 @@
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
<!-- Back to Grid Button -->
|
||||
<button
|
||||
v-if="hasMultipleImages"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.removeImage')"
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="pt-2 text-center text-xs text-base-foreground">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
<!-- Image Dimensions (gallery mode only) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
class="pt-2 text-center text-xs text-base-foreground"
|
||||
>
|
||||
<span
|
||||
v-if="imageError"
|
||||
class="text-error"
|
||||
data-testid="error-loading-image"
|
||||
>
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="showLoader" class="text-base-foreground">
|
||||
@@ -100,11 +131,23 @@
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Multiple Images Navigation -->
|
||||
|
||||
<!-- Multiple Images Navigation (gallery mode only) -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="flex flex-wrap justify-center gap-1 pt-4"
|
||||
v-if="viewMode === 'gallery' && hasMultipleImages"
|
||||
class="flex flex-wrap items-center justify-center gap-1 pt-4"
|
||||
>
|
||||
<!-- Back to Grid button -->
|
||||
<button
|
||||
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Navigation Dots -->
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
@@ -124,7 +167,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -142,7 +185,7 @@ interface ImagePreviewProps {
|
||||
readonly nodeId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
@@ -152,16 +195,19 @@ 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'
|
||||
|
||||
// Component state
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
function defaultViewMode(urls: readonly string[]): ViewMode {
|
||||
return urls.length > 1 ? 'grid' : 'gallery'
|
||||
}
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
|
||||
const galleryPanelEl = ref<HTMLDivElement>()
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const showLoader = ref(false)
|
||||
|
||||
const imageWrapperEl = ref<HTMLDivElement>()
|
||||
|
||||
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
() => {
|
||||
showLoader.value = true
|
||||
@@ -171,14 +217,23 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
|
||||
const hasMultipleImages = computed(() => imageUrls.length > 1)
|
||||
const imageAltText = computed(() =>
|
||||
t('g.viewImageOfTotal', {
|
||||
index: currentIndex.value + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
)
|
||||
const gridCols = computed(() => {
|
||||
const count = imageUrls.length
|
||||
if (count <= 4) return 2
|
||||
if (count <= 9) return 3
|
||||
return 4
|
||||
})
|
||||
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
() => imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
@@ -196,14 +251,14 @@ watch(
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
|
||||
viewMode.value = defaultViewMode(newUrls)
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
function handleImageLoad(event: Event) {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
stopDelayedLoader()
|
||||
@@ -213,29 +268,29 @@ const handleImageLoad = (event: Event) => {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
|
||||
if (props.nodeId) {
|
||||
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
|
||||
if (nodeId) {
|
||||
nodeOutputStore.syncLegacyNodeImgs(nodeId, img, currentIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
function handleImageError() {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
const handleEditMask = () => {
|
||||
if (!props.nodeId) return
|
||||
const node = resolveNode(Number(props.nodeId))
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
function handleDownload() {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
@@ -244,46 +299,35 @@ const handleDownload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!props.nodeId) return
|
||||
const node = resolveNode(Number(props.nodeId))
|
||||
nodeOutputStore.removeNodeOutputs(props.nodeId)
|
||||
if (node) {
|
||||
node.imgs = undefined
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
function setCurrentIndex(index: number) {
|
||||
if (currentIndex.value === index) return
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
|
||||
if (index >= 0 && index < imageUrls.length) {
|
||||
const urlChanged = imageUrls[index] !== currentImageUrl.value
|
||||
currentIndex.value = index
|
||||
imageError.value = false
|
||||
if (urlChanged) startDelayedLoader()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
const CLICK_THRESHOLD = 3
|
||||
let pointerStartPos = { x: 0, y: 0 }
|
||||
|
||||
function trackPointerStart(event: PointerEvent) {
|
||||
pointerStartPos = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
function handleGridThumbnailClick(event: MouseEvent, index: number) {
|
||||
const dx = event.clientX - pointerStartPos.x
|
||||
const dy = event.clientY - pointerStartPos.y
|
||||
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
|
||||
openImageInGallery(index)
|
||||
}
|
||||
|
||||
const handleFocusIn = () => {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
// Only unfocus if focus is leaving the wrapper entirely
|
||||
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
async function openImageInGallery(index: number) {
|
||||
setCurrentIndex(index)
|
||||
viewMode.value = 'gallery'
|
||||
await nextTick()
|
||||
galleryPanelEl.value?.focus()
|
||||
}
|
||||
|
||||
function getNavigationDotClass(index: number) {
|
||||
@@ -295,24 +339,30 @@ function getNavigationDotClass(index: number) {
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (props.imageUrls.length <= 1) return
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
viewMode.value === 'gallery' &&
|
||||
hasMultipleImages.value
|
||||
) {
|
||||
event.preventDefault()
|
||||
viewMode.value = 'grid'
|
||||
return
|
||||
}
|
||||
|
||||
if (imageUrls.length <= 1 || viewMode.value === 'grid') return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0
|
||||
? currentIndex.value - 1
|
||||
: props.imageUrls.length - 1
|
||||
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value < props.imageUrls.length - 1
|
||||
? currentIndex.value + 1
|
||||
: 0
|
||||
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'Home':
|
||||
@@ -321,12 +371,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(props.imageUrls.length - 1)
|
||||
setCurrentIndex(imageUrls.length - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getImageFilename = (url: string): string => {
|
||||
function getImageFilename(url: string): string {
|
||||
if (!url) return t('g.imageDoesNotExist')
|
||||
try {
|
||||
return new URL(url).searchParams.get('filename') || t('g.unknownFile')
|
||||
|
||||
@@ -304,6 +304,7 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isVideoOutput } from '@/utils/litegraphUtil'
|
||||
@@ -355,6 +356,7 @@ const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
@@ -368,7 +370,7 @@ const hasAnyError = computed((): boolean => {
|
||||
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
|
||||
(lgraphNode.value &&
|
||||
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
|
||||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
|
||||
missingNodesErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
|
||||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: headerColor }"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
@@ -73,6 +73,7 @@
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
@@ -124,7 +125,7 @@
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: headerColor }"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
@@ -145,6 +146,7 @@
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
@@ -233,6 +235,10 @@ const getTabStyles = (isBackground = false) => {
|
||||
)
|
||||
}
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
props.headerColor ? { backgroundColor: props.headerColor } : undefined
|
||||
)
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
|
||||
@@ -412,7 +412,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widgetState?.label,
|
||||
label: widget.promotedLabel ?? widgetState?.label,
|
||||
linkedUpstream,
|
||||
options: widgetOptions,
|
||||
spec: widget.spec
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { onScopeDispose, ref, toValue } from 'vue'
|
||||
import { onScopeDispose, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { useClickDragGuard } from '@/composables/useClickDragGuard'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -28,9 +29,7 @@ export function useNodePointerInteractions(
|
||||
|
||||
let hasDraggingStarted = false
|
||||
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const DRAG_THRESHOLD = 3 // pixels
|
||||
const dragGuard = useClickDragGuard(3)
|
||||
|
||||
function onPointerdown(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
@@ -57,7 +56,7 @@ export function useNodePointerInteractions(
|
||||
return
|
||||
}
|
||||
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
dragGuard.recordStart(event)
|
||||
|
||||
safeDragStart(event, nodeId)
|
||||
}
|
||||
@@ -85,11 +84,7 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
// Check if we should start dragging (pointer moved beyond threshold)
|
||||
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance > DRAG_THRESHOLD) {
|
||||
if (dragGuard.wasDragged(event)) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
handleNodeSelect(event, nodeId)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ const renderPreview = (
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
if (!img) return
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
|
||||
@@ -281,10 +281,12 @@ export interface ComfyApi extends EventTarget {
|
||||
|
||||
export class PromptExecutionError extends Error {
|
||||
response: PromptResponse
|
||||
status?: number
|
||||
|
||||
constructor(response: PromptResponse) {
|
||||
constructor(response: PromptResponse, status?: number) {
|
||||
super('Prompt execution failed')
|
||||
this.response = response
|
||||
this.status = status
|
||||
}
|
||||
|
||||
override toString() {
|
||||
@@ -901,7 +903,7 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new PromptExecutionError(errorResponse)
|
||||
throw new PromptExecutionError(errorResponse, res.status)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -83,7 +84,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import type { ExtensionManager } from '@/types/extensionTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
||||
import {
|
||||
scanAllModelCandidates,
|
||||
@@ -1105,7 +1106,9 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
|
||||
useExecutionErrorStore().surfaceMissingNodes(missingNodeTypes)
|
||||
if (useMissingNodesErrorStore().surfaceMissingNodes(missingNodeTypes)) {
|
||||
useExecutionErrorStore().showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
async loadGraphData(
|
||||
@@ -1647,6 +1650,34 @@ export class ComfyApp {
|
||||
) {
|
||||
// Re-scan the full graph instead of using the server's single-node response.
|
||||
rescanAndSurfaceMissingNodes(this.rootGraph)
|
||||
} else if (
|
||||
error instanceof PromptExecutionError &&
|
||||
error.status === 403
|
||||
) {
|
||||
// User is authenticated but not authorized (e.g. not whitelisted).
|
||||
// Show a clear message instead of a generic error or sign-in prompt.
|
||||
// The response may be middleware JSON {"message": "..."} or the
|
||||
// standard {"error": {"message": "..."}} shape, so check both.
|
||||
const raw =
|
||||
error.response && typeof error.response === 'object'
|
||||
? (error.response as Record<string, unknown>)
|
||||
: {}
|
||||
const rawError =
|
||||
raw.error && typeof raw.error === 'object'
|
||||
? (raw.error as Record<string, unknown>)
|
||||
: undefined
|
||||
const detail =
|
||||
typeof raw.message === 'string'
|
||||
? raw.message
|
||||
: typeof rawError?.message === 'string'
|
||||
? rawError.message
|
||||
: typeof raw.error === 'string'
|
||||
? raw.error
|
||||
: t('errorDialog.accessRestrictedMessage')
|
||||
useDialogService().showErrorDialog(new Error(detail), {
|
||||
title: t('errorDialog.accessRestrictedTitle'),
|
||||
reportType: 'accessRestrictedError'
|
||||
})
|
||||
} else if (
|
||||
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
||||
!(error instanceof PromptExecutionError)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
|
||||
import type { EssentialsCategory } from '@/constants/essentialsNodes'
|
||||
import {
|
||||
ESSENTIALS_CATEGORIES,
|
||||
ESSENTIALS_NODES
|
||||
ESSENTIALS_CATEGORY_CANONICAL,
|
||||
ESSENTIALS_CATEGORY_RANK,
|
||||
ESSENTIALS_NODE_RANK
|
||||
} from '@/constants/essentialsNodes'
|
||||
import { t } from '@/i18n'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -19,6 +20,34 @@ import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
|
||||
|
||||
const DEFAULT_ICON = 'pi pi-sort'
|
||||
const UNKNOWN_RANK = Number.MAX_SAFE_INTEGER
|
||||
|
||||
function resolveEssentialsCategory(
|
||||
nodeDef: ComfyNodeDefImpl
|
||||
): EssentialsCategory | undefined {
|
||||
if (!nodeDef.isCoreNode) return undefined
|
||||
|
||||
if (nodeDef.essentials_category) {
|
||||
return (
|
||||
ESSENTIALS_CATEGORY_CANONICAL.get(
|
||||
nodeDef.essentials_category.toLowerCase()
|
||||
) ?? (nodeDef.essentials_category as EssentialsCategory)
|
||||
)
|
||||
}
|
||||
return resolveBlueprintEssentialsCategory(nodeDef.name)
|
||||
}
|
||||
|
||||
function sortByKnownOrder<T>(
|
||||
items: T[],
|
||||
getKey: (item: T) => string | undefined,
|
||||
rankMap: ReadonlyMap<string, number>
|
||||
): void {
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(rankMap.get(getKey(a) ?? '') ?? UNKNOWN_RANK) -
|
||||
(rankMap.get(getKey(b) ?? '') ?? UNKNOWN_RANK)
|
||||
)
|
||||
}
|
||||
|
||||
function categoryPathExtractor(nodeDef: ComfyNodeDefImpl): string[] {
|
||||
const category = nodeDef.category || ''
|
||||
@@ -147,44 +176,39 @@ class NodeOrganizationService {
|
||||
}
|
||||
|
||||
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
|
||||
const essentialNodes = nodes.filter(
|
||||
(nodeDef) =>
|
||||
!!nodeDef.essentials_category ||
|
||||
!!resolveBlueprintEssentialsCategory(nodeDef.name)
|
||||
)
|
||||
const tree = buildNodeDefTree(essentialNodes, {
|
||||
pathExtractor: (nodeDef) => {
|
||||
const folder =
|
||||
nodeDef.essentials_category ||
|
||||
resolveBlueprintEssentialsCategory(nodeDef.name) ||
|
||||
''
|
||||
return folder ? [folder, nodeDef.name] : [nodeDef.name]
|
||||
}
|
||||
const categoryByNode = new Map<ComfyNodeDefImpl, EssentialsCategory>()
|
||||
const essentialNodes = nodes.filter((node) => {
|
||||
const category = resolveEssentialsCategory(node)
|
||||
if (!category) return false
|
||||
categoryByNode.set(node, category)
|
||||
return true
|
||||
})
|
||||
this.sortEssentialsFolders(tree)
|
||||
|
||||
const tree = buildNodeDefTree(essentialNodes, {
|
||||
pathExtractor: (node) => [categoryByNode.get(node)!, node.name]
|
||||
})
|
||||
this.sortEssentialsTree(tree)
|
||||
return [{ tree }]
|
||||
}
|
||||
|
||||
private sortEssentialsFolders(tree: TreeNode): void {
|
||||
private sortEssentialsTree(tree: TreeNode): void {
|
||||
if (!tree.children) return
|
||||
|
||||
const catLen = ESSENTIALS_CATEGORIES.length
|
||||
tree.children.sort((a, b) => {
|
||||
const ai = ESSENTIALS_CATEGORIES.indexOf(a.label as EssentialsCategory)
|
||||
const bi = ESSENTIALS_CATEGORIES.indexOf(b.label as EssentialsCategory)
|
||||
return (ai === -1 ? catLen : ai) - (bi === -1 ? catLen : bi)
|
||||
})
|
||||
sortByKnownOrder(
|
||||
tree.children,
|
||||
(node) => node.label,
|
||||
ESSENTIALS_CATEGORY_RANK
|
||||
)
|
||||
|
||||
for (const folder of tree.children) {
|
||||
if (!folder.children) continue
|
||||
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
|
||||
if (!order) continue
|
||||
const orderLen = order.length
|
||||
folder.children.sort((a, b) => {
|
||||
const ai = order.indexOf(a.data?.name ?? a.label ?? '')
|
||||
const bi = order.indexOf(b.data?.name ?? b.label ?? '')
|
||||
return (ai === -1 ? orderLen : ai) - (bi === -1 ? orderLen : bi)
|
||||
})
|
||||
const rankMap = ESSENTIALS_NODE_RANK[folder.label as EssentialsCategory]
|
||||
if (!rankMap) continue
|
||||
sortByKnownOrder(
|
||||
folder.children,
|
||||
(node) => node.data?.name ?? node.label,
|
||||
rankMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,193 +34,7 @@ vi.mock(
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
|
||||
describe('executionErrorStore — missing node operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingNodeTypes', () => {
|
||||
it('sets missingNodesError with provided types', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const types: MissingNodeType[] = [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
]
|
||||
store.setMissingNodeTypes(types)
|
||||
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingNodesError when given empty array', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
|
||||
store.setMissingNodeTypes([])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates string entries by value', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'NodeA',
|
||||
'NodeA',
|
||||
'NodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by nodeId when present', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
// Same nodeId='1' deduplicated, nodeId='2' kept
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by type when nodeId is absent', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', isReplaceable: false },
|
||||
{ type: 'NodeA', isReplaceable: true }
|
||||
] as MissingNodeType[])
|
||||
|
||||
// Same type, no nodeId → deduplicated
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps distinct nodeIds even when type is the same', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingNodes', () => {
|
||||
beforeEach(() => {
|
||||
mockShowErrorsTab.value = false
|
||||
})
|
||||
|
||||
it('stores missing node types when called', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const types: MissingNodeType[] = [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
]
|
||||
store.surfaceMissingNodes(types)
|
||||
|
||||
expect(store.missingNodesError).not.toBeNull()
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
})
|
||||
|
||||
it('opens error overlay when ShowErrorsTab setting is true', () => {
|
||||
mockShowErrorsTab.value = true
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('does not open error overlay when ShowErrorsTab setting is false', () => {
|
||||
mockShowErrorsTab.value = false
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates node types', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingNodesByType', () => {
|
||||
it('removes matching types from the missing nodes list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA', 'NodeC'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
const remaining = store.missingNodesError?.nodeTypes[0]
|
||||
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
|
||||
})
|
||||
|
||||
it('clears missingNodesError when all types are removed', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when missingNodesError is null', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
|
||||
// Should not throw
|
||||
store.removeMissingNodesByType(['NodeA'])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when removing non-existent types', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByType(['NonExistent'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles removing from string entries', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'StringNodeA',
|
||||
'StringNodeB'
|
||||
] as MissingNodeType[])
|
||||
|
||||
store.removeMissingNodesByType(['StringNodeA'])
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -537,16 +351,18 @@ describe('executionErrorStore — node error operations', () => {
|
||||
})
|
||||
|
||||
describe('clearAllErrors', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
|
||||
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
store = useExecutionErrorStore()
|
||||
executionErrorStore = useExecutionErrorStore()
|
||||
missingNodesStore = useMissingNodesErrorStore()
|
||||
})
|
||||
|
||||
it('resets all error categories and closes error overlay', () => {
|
||||
store.lastExecutionError = {
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'test',
|
||||
timestamp: 0,
|
||||
node_id: '1',
|
||||
@@ -556,8 +372,12 @@ describe('clearAllErrors', () => {
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
store.lastPromptError = { type: 'execution', message: 'fail', details: '' }
|
||||
store.lastNodeErrors = {
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: 'execution',
|
||||
message: 'fail',
|
||||
details: ''
|
||||
}
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
@@ -571,19 +391,18 @@ describe('clearAllErrors', () => {
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
store.missingNodesError = {
|
||||
message: 'Missing nodes',
|
||||
nodeTypes: [{ type: 'MissingNode', hint: '' }]
|
||||
}
|
||||
store.showErrorOverlay()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
{ type: 'MissingNode', hint: '' }
|
||||
] as unknown as MissingNodeType[])
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
store.clearAllErrors()
|
||||
executionErrorStore.clearAllErrors()
|
||||
|
||||
expect(store.lastExecutionError).toBeNull()
|
||||
expect(store.lastPromptError).toBeNull()
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
expect(store.hasAnyError).toBe(false)
|
||||
expect(executionErrorStore.lastExecutionError).toBeNull()
|
||||
expect(executionErrorStore.lastPromptError).toBeNull()
|
||||
expect(executionErrorStore.lastNodeErrors).toBeNull()
|
||||
expect(missingNodesStore.missingNodesError).toBeNull()
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
expect(executionErrorStore.hasAnyError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,122 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getAncestorExecutionIds,
|
||||
getParentExecutionIds
|
||||
} from '@/types/nodeIdentification'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
forEachNode,
|
||||
getNodeByExecutionId,
|
||||
getExecutionIdByNode,
|
||||
getActiveGraphNodeIds
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
isValueStillOutOfRange,
|
||||
SIMPLE_ERROR_TYPES
|
||||
SIMPLE_ERROR_TYPES,
|
||||
isValueStillOutOfRange
|
||||
} from '@/utils/executionErrorUtil'
|
||||
|
||||
interface MissingNodesError {
|
||||
message: string
|
||||
nodeTypes: MissingNodeType[]
|
||||
}
|
||||
|
||||
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
||||
if (node.has_errors === hasErrors) return
|
||||
const oldValue = node.has_errors
|
||||
node.has_errors = hasErrors
|
||||
node.graph?.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
oldValue,
|
||||
newValue: hasErrors
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass reconciliation of node error flags.
|
||||
* Collects the set of nodes that should have errors, then walks all nodes
|
||||
* once, setting each flag exactly once. This avoids the redundant
|
||||
* true→false→true transition (and duplicate events) that a clear-then-apply
|
||||
* approach would cause.
|
||||
*/
|
||||
function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
const flaggedNodes = new Set<LGraphNode>()
|
||||
const errorSlots = new Map<LGraphNode, Set<string>>()
|
||||
|
||||
if (nodeErrors) {
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
const node = getNodeByExecutionId(rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
flaggedNodes.add(node)
|
||||
const slotNames = new Set<string>()
|
||||
for (const error of nodeError.errors) {
|
||||
const name = error.extra_info?.input_name
|
||||
if (name) slotNames.add(name)
|
||||
}
|
||||
if (slotNames.size > 0) errorSlots.set(node, slotNames)
|
||||
|
||||
for (const parentId of getParentExecutionIds(executionId)) {
|
||||
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
||||
if (parentNode) flaggedNodes.add(parentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const execId of missingModelExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
if (node.inputs) {
|
||||
const nodeSlotNames = errorSlots.get(node)
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
|
||||
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
|
||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const lastPromptError = ref<PromptError | null>(null)
|
||||
const missingNodesError = ref<MissingNodesError | null>(null)
|
||||
|
||||
const isErrorOverlayOpen = ref(false)
|
||||
|
||||
@@ -136,7 +57,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
missingNodesError.value = null
|
||||
missingNodesStore.setMissingNodeTypes([])
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
@@ -238,14 +159,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
|
||||
}
|
||||
|
||||
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingNodes(types: MissingNodeType[]) {
|
||||
setMissingNodeTypes(types)
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
/** Set missing models and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelStore.setMissingModels(models)
|
||||
@@ -257,51 +170,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
|
||||
function removeMissingNodesByType(typesToRemove: string[]) {
|
||||
if (!missingNodesError.value) return
|
||||
const removeSet = new Set(typesToRemove)
|
||||
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
|
||||
const nodeType = typeof node === 'string' ? node : node.type
|
||||
return !removeSet.has(nodeType)
|
||||
})
|
||||
setMissingNodeTypes(remaining)
|
||||
}
|
||||
|
||||
function setMissingNodeTypes(types: MissingNodeType[]) {
|
||||
if (!types.length) {
|
||||
missingNodesError.value = null
|
||||
return
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const uniqueTypes = types.filter((node) => {
|
||||
// For string entries (group nodes), deduplicate by the string itself.
|
||||
// For object entries, prefer nodeId so multiple instances of the same
|
||||
// type are kept as separate rows; fall back to type if nodeId is absent.
|
||||
const isString = typeof node === 'string'
|
||||
let key: string
|
||||
if (isString) {
|
||||
key = node
|
||||
} else if (node.nodeId != null) {
|
||||
key = String(node.nodeId)
|
||||
} else {
|
||||
key = node.type
|
||||
}
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
missingNodesError.value = {
|
||||
message: isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
nodeTypes: uniqueTypes
|
||||
}
|
||||
}
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
@@ -323,14 +191,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
const hasMissingNodes = computed(() => !!missingNodesError.value)
|
||||
|
||||
const hasAnyError = computed(
|
||||
() =>
|
||||
hasExecutionError.value ||
|
||||
hasPromptError.value ||
|
||||
hasNodeError.value ||
|
||||
hasMissingNodes.value ||
|
||||
missingNodesStore.hasMissingNodes ||
|
||||
missingModelStore.hasMissingModels
|
||||
)
|
||||
|
||||
@@ -361,14 +227,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
|
||||
|
||||
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
|
||||
|
||||
const totalErrorCount = computed(
|
||||
() =>
|
||||
promptErrorCount.value +
|
||||
nodeErrorCount.value +
|
||||
executionErrorCount.value +
|
||||
missingNodeCount.value +
|
||||
missingNodesStore.missingNodeCount +
|
||||
missingModelStore.missingModelCount
|
||||
)
|
||||
|
||||
@@ -400,37 +264,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return ids
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing node execution IDs,
|
||||
* including the missing nodes themselves.
|
||||
*
|
||||
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
const error = missingNodesError.value
|
||||
if (!error) return ids
|
||||
|
||||
for (const nodeType of error.nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.isGraphReady) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
@@ -493,42 +326,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
/** True if the node has a missing node inside it at any nesting depth. */
|
||||
function isContainerWithMissingNode(node: LGraphNode): boolean {
|
||||
if (!app.isGraphReady) return false
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return false
|
||||
return missingAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
watch(
|
||||
[lastNodeErrors, () => missingModelStore.missingModelNodeIds],
|
||||
() => {
|
||||
if (!app.isGraphReady) return
|
||||
// Legacy (LGraphNode) only: suppress missing-model error flags when
|
||||
// the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
const showErrorsTab = useSettingStore().get(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab'
|
||||
)
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
missingNodesError,
|
||||
|
||||
// Clearing
|
||||
clearAllErrors,
|
||||
@@ -543,30 +347,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
hasExecutionError,
|
||||
hasPromptError,
|
||||
hasNodeError,
|
||||
hasMissingNodes,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
activeGraphErrorNodeIds,
|
||||
activeMissingNodeGraphIds,
|
||||
|
||||
// Clearing (targeted)
|
||||
clearSimpleNodeErrors,
|
||||
clearWidgetRelatedErrors,
|
||||
|
||||
// Missing node actions
|
||||
setMissingNodeTypes,
|
||||
surfaceMissingNodes,
|
||||
removeMissingNodesByType,
|
||||
|
||||
// Missing model coordination (delegates to missingModelStore)
|
||||
surfaceMissingModels,
|
||||
|
||||
// Lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
isContainerWithInternalError,
|
||||
isContainerWithMissingNode
|
||||
isContainerWithInternalError
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
@@ -598,13 +599,13 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
let store: ReturnType<typeof useMissingNodesErrorStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionErrorStore()
|
||||
store = useMissingNodesErrorStore()
|
||||
})
|
||||
|
||||
it('clears missingNodesError when called with an empty array', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user