mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 04:47:34 +00:00
Compare commits
7 Commits
refactor/e
...
fix/node-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85417836dc | ||
|
|
b8a988c235 | ||
|
|
92cf138712 | ||
|
|
de08700fa0 | ||
|
|
461c71670b | ||
|
|
c47f89ed28 | ||
|
|
feb8555013 |
@@ -1,172 +0,0 @@
|
||||
{
|
||||
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"pos": [689.0083557128902, 467.9999999999997],
|
||||
"size": [431.8999938964844, 206.60000610351562],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "text", "2"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [330, 367, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [983, 367, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [510, 166],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["11111111111"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [523, 438],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["22222222222"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [467, 446, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [932, 446, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"pos": [647, 389],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "text"],
|
||||
["2", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 2.0975,
|
||||
"offset": [-581.4780189305006, -356.3000030517576]
|
||||
},
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
139
browser_tests/assets/vueNodes/overlapping-with-text.json
Normal file
139
browser_tests/assets/vueNodes/overlapping-with-text.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"id": "b7e1a3f0-text-bleed-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500, 300],
|
||||
"size": [240, 155],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "CLIPTextEncode" },
|
||||
"widgets_values": ["beautiful scenery nature glass bottle landscape"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": [500, 300],
|
||||
"size": [428, 437],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"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": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": { "name": "steps" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "cfg" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": { "name": "sampler_name" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": { "name": "scheduler" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "denoise" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-200, -100]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
@@ -177,7 +176,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -223,13 +221,12 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
this.settings = new SettingsHelper(page)
|
||||
this.keyboard = new KeyboardHelper(page, this.canvas)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard, page)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard)
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
@@ -290,7 +287,9 @@ export class ComfyPage {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
await this.goto()
|
||||
|
||||
// Mock release endpoint to prevent changelog popups
|
||||
if (mockReleases) {
|
||||
await this.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
@@ -310,16 +309,12 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
if (clearStorage) {
|
||||
// Navigate to a lightweight same-origin endpoint to obtain a page
|
||||
// context for clearing storage without loading the full frontend app.
|
||||
await this.page.goto(`${this.url}/api/users`)
|
||||
await this.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class AppModeHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get builderToolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Exit builder mode via the footer "Exit app builder" button. */
|
||||
async exitBuilder() {
|
||||
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Inputs" step in the builder toolbar. */
|
||||
async goToInputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Outputs" step in the builder toolbar. */
|
||||
async goToOutputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Preview" step in the builder toolbar. */
|
||||
async goToPreview() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Next" button in the builder footer. */
|
||||
async next() {
|
||||
await this.page.getByRole('button', { name: 'Next' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Back" button in the builder footer. */
|
||||
async back() {
|
||||
await this.page.getByRole('button', { name: 'Back' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
*/
|
||||
getAppModeWidgetMenu(widgetName: string): Locator {
|
||||
return this.linearWidgets
|
||||
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder input-select
|
||||
* sidebar (IoItem).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getBuilderInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({ hasText: title })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder preview/arrange
|
||||
* sidebar (AppModeWidgetList with builderMode).
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.locator(`[aria-label="${ariaLabel}"]`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from './KeyboardHelper'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
constructor(private readonly keyboard: KeyboardHelper) {}
|
||||
|
||||
async copy(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyC', locator ?? null)
|
||||
@@ -19,44 +12,4 @@ export class ClipboardHelper {
|
||||
async paste(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from 'fs'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(
|
||||
@@ -49,8 +48,19 @@ export class DragDropHelper {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileType = getFileType(fileName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.graph` (the root workflow graph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
@@ -7,7 +6,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '../utils/litegraphUtils'
|
||||
|
||||
@@ -324,93 +322,4 @@ export class SubgraphHelper {
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async exitViaBreadcrumb(): Promise<void> {
|
||||
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
if (await parentLink.isVisible()) {
|
||||
await parentLink.click()
|
||||
} else {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph
|
||||
if (!graph) return
|
||||
canvas.setGraph(graph.rootGraph)
|
||||
})
|
||||
}
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.reduce((count, node) => {
|
||||
const proxyWidgets = node.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) return count
|
||||
|
||||
return (
|
||||
count +
|
||||
proxyWidgets.filter(
|
||||
(entry) =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[1] === 'string' &&
|
||||
entry[1].startsWith('$$')
|
||||
).length
|
||||
)
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export function getMimeType(fileName: string): string {
|
||||
const name = fileName.toLowerCase()
|
||||
if (name.endsWith('.png')) return 'image/png'
|
||||
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (name.endsWith('.webp')) return 'image/webp'
|
||||
if (name.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
@@ -28,12 +28,9 @@ export const TestIds = {
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
@@ -61,10 +58,6 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
},
|
||||
@@ -74,10 +67,6 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -95,11 +84,9 @@ export type TestIdValue =
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -29,8 +29,7 @@ export const webSocketFixture = base.extend<{
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
const u = new URL(window.location.toString())
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
|
||||
@@ -75,26 +75,6 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
export async function getPseudoPreviewWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
await page.mouse.click(seedPos.x, seedPos.y)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
const saveImagePos = await saveImageRef.getPosition()
|
||||
// Click left edge — the right side is hidden by the builder panel
|
||||
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Go back to inputs step where IoItems are shown
|
||||
await appMode.goToInputs()
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
appMode.linearWidgets.getByText('Builder Input Seed')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToPreview()
|
||||
|
||||
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-preview`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from app mode', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Enter app mode from builder
|
||||
await appMode.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||
await appMode.renameWidget(menu, 'App Mode Seed')
|
||||
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
|
||||
// Verify persistence after save/reload
|
||||
await appMode.toggleAppMode()
|
||||
const workflowName = `${new Date().getTime()} app-mode`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -129,74 +129,4 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(undoCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test(
|
||||
'Copy paste node, image paste onto LoadImage, image paste on empty canvas',
|
||||
{ tag: ['@node'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
|
||||
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(3)
|
||||
|
||||
// Step 2: Paste image onto selected LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||
return fileWidget.getValue()
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toContain('image32x32')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 3: Click empty canvas area, paste image → creates new LoadImage
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise2
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(4)
|
||||
const allLoadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(allLoadImageNodes).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
@@ -144,42 +144,6 @@ test.describe('Execution error', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for error overlay and click "See Errors"
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).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'
|
||||
})
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -204,7 +168,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -220,7 +184,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -231,13 +195,13 @@ 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()
|
||||
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
|
||||
@@ -24,20 +24,6 @@ test.describe(
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const minimapButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(minimapButton).toBeVisible()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-graph-canvas-toolbar.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
@@ -79,7 +79,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
'Can auto link batch moved node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
|
||||
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
|
||||
|
||||
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function runAndOpenGallery(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
// Wait for SaveImage node to produce output
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
// Open Assets sidebar tab and wait for it to load
|
||||
await comfyPage.page.locator('.assets-tab-button').click()
|
||||
await comfyPage.page
|
||||
.locator('.sidebar-content-container')
|
||||
.waitFor({ state: 'visible' })
|
||||
|
||||
// Wait for any asset card to appear (may contain img or video)
|
||||
const assetCard = comfyPage.page
|
||||
.locator('[role="button"]')
|
||||
.filter({ has: comfyPage.page.locator('img, video') })
|
||||
.first()
|
||||
|
||||
await expect(assetCard).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Hover to reveal zoom button, then click it
|
||||
await assetCard.hover()
|
||||
await assetCard.getByLabel('Zoom in').click()
|
||||
|
||||
const gallery = comfyPage.page.getByRole('dialog')
|
||||
await expect(gallery).toBeVisible()
|
||||
|
||||
return { gallery }
|
||||
}
|
||||
|
||||
test('opens gallery and shows dialog with close button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { gallery } = await runAndOpenGallery(comfyPage)
|
||||
await expect(gallery.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes gallery on Escape key', async ({ comfyPage }) => {
|
||||
await runAndOpenGallery(comfyPage)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes gallery when clicking close button', async ({ comfyPage }) => {
|
||||
const { gallery } = await runAndOpenGallery(comfyPage)
|
||||
|
||||
await gallery.getByLabel('Close').click()
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -123,7 +123,6 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
})
|
||||
|
||||
test('Can pin and unpin', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick,
|
||||
button: 'right'
|
||||
|
||||
@@ -43,31 +43,6 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -655,28 +654,6 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
@@ -790,9 +767,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
// In the subgraph navigation breadcrumbs, the home/top level
|
||||
// breadcrumb is just the workflow name without the folder path
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -1,347 +1,160 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
getPromotedWidgetSnapshot,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
|
||||
test('hydrates legacy proxyWidgets deterministically across reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-duplicate-ids'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
|
||||
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-duplicate-ids'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
|
||||
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
|
||||
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
|
||||
})
|
||||
|
||||
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const projection = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
const hostNode = graph.getNodeById('11')
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
)
|
||||
throw new Error('Expected host subgraph node 11')
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
const beforeType = hostNode.widgets?.[0]?.type
|
||||
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
|
||||
? hostNode.properties.proxyWidgets.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
: []
|
||||
const firstPromotion = proxyWidgets[0]
|
||||
if (!firstPromotion)
|
||||
throw new Error('Expected at least one promoted widget entry')
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
const [sourceNodeId, sourceWidgetName] = firstPromotion
|
||||
const subgraph = graph.subgraphs.get(hostNode.type)
|
||||
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
|
||||
if (!sourceNode?.widgets)
|
||||
throw new Error('Expected promoted source node widget list')
|
||||
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
sourceNode.widgets = sourceNode.widgets.filter(
|
||||
(widget) => widget.name !== sourceWidgetName
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
return {
|
||||
beforeType,
|
||||
afterType: hostNode.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(projection.beforeType).toBe('customtext')
|
||||
expect(projection.afterType).toBe('button')
|
||||
})
|
||||
|
||||
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
|
||||
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
|
||||
|
||||
const cleanupResult = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const invalidPseudoEntries = () => {
|
||||
const invalid: string[] = []
|
||||
for (const node of graph.nodes) {
|
||||
if (
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
)
|
||||
continue
|
||||
|
||||
const subgraph = graph.subgraphs.get(node.type)
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
: []
|
||||
for (const entry of proxyWidgets) {
|
||||
if (entry[1] !== '$$canvas-image-preview') continue
|
||||
|
||||
const sourceNodeId = Number(entry[0])
|
||||
const sourceNode = subgraph?.getNodeById(sourceNodeId)
|
||||
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
|
||||
}
|
||||
}
|
||||
return invalid
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
const before = invalidPseudoEntries()
|
||||
const hostNode = graph.getNodeById('7')
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
)
|
||||
throw new Error('Expected preview host subgraph node 7')
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
;(
|
||||
graph as unknown as { unpackSubgraph: (node: unknown) => void }
|
||||
).unpackSubgraph(hostNode)
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
const serialized1 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized1 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
const serialized2 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized2 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
return {
|
||||
before,
|
||||
after: invalidPseudoEntries(),
|
||||
hasNode7: Boolean(graph.getNodeById('7')),
|
||||
hasNode8: Boolean(graph.getNodeById('8'))
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
expect(cleanupResult.before).toEqual([])
|
||||
expect(cleanupResult.after).toEqual([])
|
||||
expect(cleanupResult.hasNode7).toBe(false)
|
||||
expect(cleanupResult.hasNode8).toBe(true)
|
||||
|
||||
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const hostNode = window.app!.canvas.graph!.getNodeById('11')
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return {
|
||||
proxyWidgetCount: Array.isArray(proxyWidgets)
|
||||
? proxyWidgets.length
|
||||
: 0,
|
||||
firstWidgetType: hostNode?.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
})
|
||||
.toEqual({
|
||||
proxyWidgetCount: initialWidgets.length,
|
||||
firstWidgetType: 'button'
|
||||
})
|
||||
})
|
||||
|
||||
test('Promoted widget disappears from DOM after interior node deletion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Non-preview widgets coexist with pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
|
||||
comfyPage,
|
||||
'5'
|
||||
)
|
||||
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.filter((n) => n.isSubgraphNode()).length
|
||||
})
|
||||
expect(subgraphNodeCount).toBe(0)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('5')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
|
||||
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
|
||||
expect(firstNodeBefore.length).toBeGreaterThan(0)
|
||||
expect(secondNodeBefore.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('7')
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.graph!.getNodeById('7')
|
||||
})
|
||||
expect(firstNodeExists).toBe(false)
|
||||
|
||||
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
expect(secondNodeAfter).toEqual(secondNodeBefore)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
|
||||
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const warnings: string[] = []
|
||||
comfyPage.page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (
|
||||
text.includes('No link found') ||
|
||||
text.includes('Failed to resolve legacy -1') ||
|
||||
text.includes('No inner link found')
|
||||
) {
|
||||
warnings.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
expect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
|
||||
expect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
expect(values).toContain('11111111111')
|
||||
expect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await expect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -73,59 +73,5 @@ test.describe(
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return subgraphNode ? String(subgraphNode.id) : null
|
||||
})
|
||||
expect(subgraphNodeId).not.toBeNull()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId!)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
subgraphNodeId!
|
||||
)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
@@ -11,6 +12,25 @@ import {
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Check whether we're currently in a subgraph.
|
||||
*/
|
||||
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas.graph) return
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
@@ -170,7 +190,7 @@ test.describe(
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
@@ -242,7 +262,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
@@ -276,7 +296,7 @@ test.describe(
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -322,7 +342,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -357,7 +377,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -388,7 +408,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -544,30 +564,6 @@ test.describe(
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
}, serialized as ComfyWorkflowJSON)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -725,44 +721,6 @@ test.describe(
|
||||
expect(nodeExists).toBe(false)
|
||||
})
|
||||
|
||||
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(initialNames.length).toBeGreaterThan(0)
|
||||
|
||||
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await outerSubgraph.navigateIntoSubgraph()
|
||||
|
||||
const removedSlotName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
return graph.inputs?.[0]?.name ?? null
|
||||
})
|
||||
expect(removedSlotName).not.toBeNull()
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
const expectedNames = [...initialNames]
|
||||
const removedIndex = expectedNames.indexOf(removedSlotName!)
|
||||
expect(removedIndex).toBeGreaterThanOrEqual(0)
|
||||
expectedNames.splice(removedIndex, 1)
|
||||
|
||||
expect(finalNames).toEqual(expectedNames)
|
||||
})
|
||||
|
||||
test('Removing I/O slot removes associated promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -785,7 +743,15 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
@@ -2,116 +2,9 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../fixtures/ComfyPage'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
type NodeGroupCenteringError = {
|
||||
horizontal: number
|
||||
vertical: number
|
||||
}
|
||||
|
||||
type NodeGroupCenteringErrors = {
|
||||
innerGroup: NodeGroupCenteringError
|
||||
outerGroup: NodeGroupCenteringError
|
||||
}
|
||||
|
||||
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
|
||||
innerGroup: {
|
||||
horizontal: 16.308832840862777,
|
||||
vertical: 17.390899314547084
|
||||
},
|
||||
outerGroup: {
|
||||
horizontal: 20.30164329441476,
|
||||
vertical: 42.196324096481476
|
||||
}
|
||||
} as const
|
||||
|
||||
const CENTERING_TOLERANCE = {
|
||||
innerGroup: 6,
|
||||
outerGroup: 12
|
||||
} as const
|
||||
|
||||
function expectWithinBaseline(
|
||||
actual: number,
|
||||
baseline: number,
|
||||
tolerance: number
|
||||
) {
|
||||
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
|
||||
}
|
||||
|
||||
async function getNodeGroupCenteringErrors(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeGroupCenteringErrors> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
type GraphNode = {
|
||||
id: number | string
|
||||
pos: ReadonlyArray<number>
|
||||
}
|
||||
type GraphGroup = {
|
||||
title: string
|
||||
pos: ReadonlyArray<number>
|
||||
size: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
const app = window.app!
|
||||
const node = app.graph.nodes[0] as GraphNode | undefined
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Expected a node in the loaded workflow')
|
||||
}
|
||||
|
||||
const nodeElement = document.querySelector<HTMLElement>(
|
||||
`[data-node-id="${node.id}"]`
|
||||
)
|
||||
|
||||
if (!nodeElement) {
|
||||
throw new Error(`Vue node element not found for node ${node.id}`)
|
||||
}
|
||||
|
||||
const groups = app.graph.groups as GraphGroup[]
|
||||
const innerGroup = groups.find((group) => group.title === 'Inner Group')
|
||||
const outerGroup = groups.find((group) => group.title === 'Outer Group')
|
||||
|
||||
if (!innerGroup || !outerGroup) {
|
||||
throw new Error('Expected both Inner Group and Outer Group in graph')
|
||||
}
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
|
||||
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
|
||||
group.pos[0],
|
||||
group.pos[1]
|
||||
])
|
||||
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
|
||||
group.pos[0] + group.size[0],
|
||||
group.pos[1] + group.size[1]
|
||||
])
|
||||
|
||||
const groupLeft = Math.min(groupStartX, groupEndX)
|
||||
const groupRight = Math.max(groupStartX, groupEndX)
|
||||
const groupTop = Math.min(groupStartY, groupEndY)
|
||||
const groupBottom = Math.max(groupStartY, groupEndY)
|
||||
|
||||
const leftGap = nodeRect.left - groupLeft
|
||||
const rightGap = groupRight - nodeRect.right
|
||||
const topGap = nodeRect.top - groupTop
|
||||
const bottomGap = groupBottom - nodeRect.bottom
|
||||
|
||||
return {
|
||||
horizontal: Math.abs(leftGap - rightGap),
|
||||
vertical: Math.abs(topGap - bottomGap)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
innerGroup: getCenteringError(innerGroup),
|
||||
outerGroup: getCenteringError(outerGroup)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -181,45 +74,4 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
|
||||
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
|
||||
const extra = window.app!.graph.extra as
|
||||
| { workflowRendererVersion?: string }
|
||||
| undefined
|
||||
return extra?.workflowRendererVersion
|
||||
})
|
||||
|
||||
expect(workflowRendererVersion).toMatch(/^Vue/)
|
||||
|
||||
await expect(async () => {
|
||||
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
|
||||
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../../../../helpers/fitToView'
|
||||
|
||||
test.describe('Vue Node Text Bleed-Through', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/overlapping-with-text')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
test('overlapping node should not show text from node beneath', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-text-no-bleed-through.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -20,14 +19,10 @@ test.describe('Vue Upload Widgets', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
|
||||
)
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
|
||||
)
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# 7. NodeExecutionOutput Passthrough Schema Design
|
||||
|
||||
Date: 2026-03-11
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
|
||||
|
||||
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
|
||||
|
||||
```ts
|
||||
const zOutputs = z
|
||||
.object({
|
||||
audio: z.array(zResultItem).optional(),
|
||||
images: z.array(zResultItem).optional(),
|
||||
video: z.array(zResultItem).optional(),
|
||||
animated: z.array(z.boolean()).optional(),
|
||||
text: z.union([z.string(), z.array(z.string())]).optional()
|
||||
})
|
||||
.passthrough()
|
||||
```
|
||||
|
||||
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
|
||||
|
||||
### Why not `.catchall(z.array(zResultItem))`?
|
||||
|
||||
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
|
||||
|
||||
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
|
||||
|
||||
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
|
||||
|
||||
### Why not remove `animated` and `text` from the schema?
|
||||
|
||||
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
|
||||
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
|
||||
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
|
||||
|
||||
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
|
||||
|
||||
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
|
||||
|
||||
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
|
||||
- Consistent validation strictness across all code paths
|
||||
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
|
||||
- The `unknown[]` cast is contained to one location
|
||||
|
||||
### Negative
|
||||
|
||||
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
|
||||
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
|
||||
|
||||
## Notes
|
||||
|
||||
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
|
||||
|
||||
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.
|
||||
@@ -8,15 +8,13 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
|
||||
## ADR Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| ADR | Title | Status | Date |
|
||||
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
@@ -1901,37 +1901,3 @@ audio.comfy-audio.empty-audio-widget {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-shadows-* {
|
||||
overflow: auto;
|
||||
|
||||
background:
|
||||
/* Shadow Cover TOP */
|
||||
linear-gradient(--value(--color-*) 30%, transparent) center top,
|
||||
/* Shadow Cover BOTTOM */
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
/* Shadow TOP */
|
||||
radial-gradient(
|
||||
farthest-side at 50% 0,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
transparent
|
||||
)
|
||||
center top,
|
||||
/* Shadow BOTTOM */
|
||||
radial-gradient(
|
||||
farthest-side at 50% 100%,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
transparent
|
||||
)
|
||||
center bottom;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
300% 40px,
|
||||
300% 40px,
|
||||
300% 14px,
|
||||
300% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -461,9 +461,6 @@ importers:
|
||||
'@vueuse/integrations':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))
|
||||
'@vueuse/router':
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -4430,12 +4427,6 @@ packages:
|
||||
'@vueuse/metadata@14.2.0':
|
||||
resolution: {integrity: sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==}
|
||||
|
||||
'@vueuse/router@14.2.1':
|
||||
resolution: {integrity: sha512-SbZfJe+qn5bj78zNOXT4nYbnp8OIFMyAsdcJb4Y0y9vXi1TsOfglF+YIazi5DPO2lk6/ZukpN5DEQe6KrNOjMw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
vue-router: ^4.0.0 || ^5.0.0
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
@@ -4444,11 +4435,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@webgpu/types@0.1.66':
|
||||
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
|
||||
|
||||
@@ -12809,12 +12795,6 @@ snapshots:
|
||||
|
||||
'@vueuse/metadata@14.2.0': {}
|
||||
|
||||
'@vueuse/router@14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
@@ -12825,10 +12805,6 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@webgpu/types@0.1.66': {}
|
||||
|
||||
'@xstate/fsm@1.6.5': {}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
.disable-animations *::before,
|
||||
.disable-animations *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* List transition animations */
|
||||
.list-scale-move,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Regression test: the graph-canvas-panel SplitterPanel must not clip
|
||||
* absolutely-positioned children (like GraphCanvasMenu).
|
||||
*
|
||||
* PrimeVue applies `overflow: hidden` to all SplitterPanels by default.
|
||||
* Without an explicit `overflow-visible` override, the bottom-right canvas
|
||||
* toolbar becomes invisible on mobile viewports where the panel's bounding
|
||||
* box is smaller than the full canvas area.
|
||||
*
|
||||
* @see https://www.notion.so/Bug-Graph-canvas-toolbar-not-visible-on-mobile-3246d73d36508144ae00f10065c42fac
|
||||
*/
|
||||
describe('LiteGraphCanvasSplitterOverlay', () => {
|
||||
it('graph-canvas-panel has overflow-visible to prevent clipping toolbar on mobile', () => {
|
||||
const filePath = resolve(__dirname, 'LiteGraphCanvasSplitterOverlay.vue')
|
||||
const source = readFileSync(filePath, 'utf-8')
|
||||
|
||||
// The SplitterPanel wrapping graph-canvas-panel must include overflow-visible
|
||||
// to override PrimeVue's default overflow:hidden on .p-splitterpanel.
|
||||
// Without this, GraphCanvasMenu (absolute right-0 bottom-0) gets clipped on mobile.
|
||||
expect(source).toMatch(
|
||||
/class="[^"]*graph-canvas-panel[^"]*overflow-visible/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,7 @@
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: tooltipText,
|
||||
showDelay: 512
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
@@ -22,7 +23,7 @@
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</div>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive || isRoot"
|
||||
ref="menu"
|
||||
|
||||
@@ -10,8 +10,6 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
@@ -19,7 +17,6 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -41,7 +38,6 @@ const { mobile = false, builderMode = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
@@ -101,27 +97,21 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
|
||||
const { filename, subfolder, type } = stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
const filename = node.widgets?.[0]?.value
|
||||
const resultItem = { type: 'input', filename: `${filename}` }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
appendCloudResParam(params, filename)
|
||||
const params = new URLSearchParams(resultItem)
|
||||
appendCloudResParam(params, resultItem.filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
const imageUrl = buildImageUrl()
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl,
|
||||
imageUrl: buildImageUrl(),
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,21 +127,6 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragDrop(e: DragEvent) {
|
||||
for (const { nodeData } of mappedSelections.value) {
|
||||
if (!nodeData?.onDragOver?.(e)) continue
|
||||
|
||||
const rawResult = nodeData?.onDragDrop?.(e)
|
||||
if (rawResult === false) continue
|
||||
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if ((await rawResult) === true) return
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -206,11 +181,7 @@ defineExpose({ handleDragDrop })
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
data-testid="widget-actions-menu"
|
||||
>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -63,7 +63,7 @@ const entries = computed(() => {
|
||||
</div>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly" data-testid="widget-actions-menu">
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ImageLightbox from './ImageLightbox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function findCloseButton() {
|
||||
const el = document.body.querySelector('[aria-label="g.close"]')
|
||||
return el ? new DOMWrapper(el) : null
|
||||
}
|
||||
|
||||
describe(ImageLightbox, () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
function mountComponent(props: { src: string; alt?: string }, open = true) {
|
||||
wrapper = mount(ImageLightbox, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { ...props, modelValue: open },
|
||||
attachTo: document.body
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('renders the image with correct src and alt when open', async () => {
|
||||
mountComponent({ src: '/test.png', alt: 'Test image' })
|
||||
await flushPromises()
|
||||
const img = document.body.querySelector('img')
|
||||
expect(img).toBeTruthy()
|
||||
expect(img?.getAttribute('src')).toBe('/test.png')
|
||||
expect(img?.getAttribute('alt')).toBe('Test image')
|
||||
})
|
||||
|
||||
it('does not render dialog content when closed', async () => {
|
||||
mountComponent({ src: '/test.png' }, false)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits update:modelValue false when close button is clicked', async () => {
|
||||
mountComponent({ src: '/test.png' })
|
||||
await flushPromises()
|
||||
const closeButton = findCloseButton()
|
||||
expect(closeButton).toBeTruthy()
|
||||
await closeButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
VisuallyHidden
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const { src, alt = '' } = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-30 bg-black/60 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1700 -translate-1/2 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-50"
|
||||
@escape-key-down="open = false"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{{ alt || t('g.imageLightbox') }}</DialogTitle>
|
||||
<DialogDescription v-if="alt">{{ alt }}</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="absolute -top-2 -right-2 z-10 translate-x-full text-white hover:text-white/80"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<img
|
||||
:src
|
||||
:alt
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -12,7 +12,6 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -24,7 +23,6 @@ const { source, align = 'start' } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
@@ -45,16 +43,6 @@ function handleOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
@@ -64,14 +52,7 @@ function toggleLinearMode() {
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
transform: 'translateX(calc(50% - 16px))',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 'none'
|
||||
}
|
||||
},
|
||||
text: {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
style: { transform: 'translateX(calc(50% - 16px))' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
@@ -80,18 +61,16 @@ const tooltipPt = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-model:open="dropdownOpen"
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
value: canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
|
||||
@@ -19,7 +19,10 @@ const props = defineProps<{
|
||||
|
||||
const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
function openGitHubIssues() {
|
||||
/**
|
||||
* Open GitHub issues search and track telemetry.
|
||||
*/
|
||||
const openGitHubIssues = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
|
||||
@@ -164,11 +164,9 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
|
||||
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -209,7 +207,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { linearMode } = storeToRefs(canvasStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const toastStore = useToastStore()
|
||||
@@ -282,22 +279,6 @@ watch(
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
watch(
|
||||
() => linearMode.value,
|
||||
(isLinearMode) => {
|
||||
if (!shouldRenderVueNodes.value) return
|
||||
|
||||
if (isLinearMode) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
} else {
|
||||
// App mode hides the graph canvas with `display: none`, so slot connectors
|
||||
// need a fresh DOM measurement pass before links can render correctly.
|
||||
requestSlotLayoutSyncForAllNodes()
|
||||
}
|
||||
|
||||
layoutStore.setPendingSlotSync(true)
|
||||
}
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<template>
|
||||
<span role="status" class="inline-flex">
|
||||
<i
|
||||
v-if="variant === 'loader'"
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--loader]', sizeClass)"
|
||||
>
|
||||
<div
|
||||
class="size-full animate-spin bg-conic from-base-foreground from-10% to-muted-foreground to-10%"
|
||||
/>
|
||||
</i>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--loader-circle] animate-spin', sizeClass)"
|
||||
/>
|
||||
@@ -25,7 +15,6 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { size } = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'loader-circle' | 'loader'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -60,7 +60,7 @@ const mountComponent = (
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
MediaLightbox: true
|
||||
ResultGallery: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
|
||||
@@ -9,15 +9,12 @@ 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'
|
||||
@@ -41,21 +38,12 @@ 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 } = storeToRefs(executionErrorStore)
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.isGraphReady) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingNodesErrorStore.missingAncestorExecutionIds
|
||||
)
|
||||
})
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
const mockGenerateErrorReport = vi.fn(
|
||||
(_data?: unknown) => '# ComfyUI Error Report\n...'
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: () => mockGetLogs()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: () => mockSerialize()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/errorReportUtil', () => ({
|
||||
generateErrorReport: (data: unknown) => mockGenerateErrorReport(data)
|
||||
}))
|
||||
|
||||
const mockTrackHelpResourceClicked = vi.fn()
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockExecuteCommand = vi.fn()
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn(() => ({
|
||||
execute: mockExecuteCommand
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('ErrorNodeCard.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cardIdCounter = 0
|
||||
mockGetLogs.mockResolvedValue('mock server logs')
|
||||
mockGenerateErrorReport.mockReturnValue('# ComfyUI Error Report\n...')
|
||||
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
copy: 'Copy',
|
||||
findIssues: 'Find Issues',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
'Report this error and we\u0027ll help you resolve it'
|
||||
},
|
||||
issueReport: {
|
||||
helpFix: 'Help Fix This'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mountCard(card: ErrorCardData) {
|
||||
return mount(ErrorNodeCard, {
|
||||
props: { card },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
systemStats: {
|
||||
systemStats: {
|
||||
system: {
|
||||
os: 'Linux',
|
||||
python_version: '3.11.0',
|
||||
embedded_python: false,
|
||||
comfyui_version: '1.0.0',
|
||||
pytorch_version: '2.1.0',
|
||||
argv: ['--listen']
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'NVIDIA RTX 4090',
|
||||
type: 'cuda',
|
||||
vram_total: 24000,
|
||||
vram_free: 12000,
|
||||
torch_vram_total: 24000,
|
||||
torch_vram_free: 12000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cardIdCounter = 0
|
||||
|
||||
function makeRuntimeErrorCard(): ErrorCardData {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'Traceback line 1\nTraceback line 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function makeValidationErrorCard(): ErrorCardData {
|
||||
return {
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '6',
|
||||
nodeTitle: 'CLIP Text Encode',
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: text'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('displays enriched report for runtime errors on mount', async () => {
|
||||
const reportText =
|
||||
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('ComfyUI Error Report')
|
||||
expect(wrapper.text()).toContain('System Information')
|
||||
expect(wrapper.text()).toContain('OS: Linux')
|
||||
})
|
||||
|
||||
it('does not generate report for non-runtime errors', async () => {
|
||||
mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetLogs).not.toHaveBeenCalled()
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays original details for non-runtime errors', async () => {
|
||||
const wrapper = mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Input: text')
|
||||
expect(wrapper.text()).not.toContain('ComfyUI Error Report')
|
||||
})
|
||||
|
||||
it('copies enriched report when copy button is clicked for runtime error', async () => {
|
||||
const reportText = '# Full Report Content'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))!
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('copyToClipboard')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted![0][0]).toContain('# Full Report Content')
|
||||
})
|
||||
|
||||
it('copies original details when copy button is clicked for validation error', async () => {
|
||||
const wrapper = mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))!
|
||||
await copyButton.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('copyToClipboard')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted![0][0]).toBe('Required input is missing\n\nInput: text')
|
||||
})
|
||||
|
||||
it('generates report with fallback logs when getLogs fails', async () => {
|
||||
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
// 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')
|
||||
})
|
||||
|
||||
it('falls back to original details when generateErrorReport throws', async () => {
|
||||
mockGenerateErrorReport.mockImplementation(() => {
|
||||
throw new Error('Serialization error')
|
||||
})
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Traceback line 1')
|
||||
})
|
||||
|
||||
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
const findIssuesButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Find on GitHub'))!
|
||||
expect(findIssuesButton.exists()).toBe(true)
|
||||
|
||||
await findIssuesButton.trigger('click')
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('CUDA%20out%20of%20memory'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('executes ContactSupport command when Get Help button is clicked', async () => {
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
const getHelpButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Get Help'))!
|
||||
expect(getHelpButton.exists()).toBe(true)
|
||||
|
||||
await getHelpButton.trigger('click')
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'help_feedback',
|
||||
source: 'error_dialog'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes exceptionType from error item to report generator', async () => {
|
||||
mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses fallback exception type when error item has no exceptionType', async () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Unknown error occurred',
|
||||
details: 'Some traceback',
|
||||
isRuntimeError: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mountCard(card)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exceptionType: 'Runtime Error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to original details when systemStats is unavailable', async () => {
|
||||
const wrapper = mount(ErrorNodeCard, {
|
||||
props: { card: makeRuntimeErrorCard() },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
systemStats: { systemStats: null }
|
||||
}
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('Traceback line 1')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
@@ -12,10 +12,10 @@
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle || card.title"
|
||||
v-if="card.nodeTitle"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
@@ -40,19 +40,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div class="space-y-4 divide-y divide-interface-stroke/20">
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-col gap-3',
|
||||
fullHeight && error.isRuntimeError && 'flex-1'
|
||||
)
|
||||
"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
@@ -62,61 +55,32 @@
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details (enriched with full report for runtime errors) -->
|
||||
<!-- Traceback / Details -->
|
||||
<div
|
||||
v-if="displayedDetailsMap[idx]"
|
||||
v-if="error.details"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
|
||||
error.isRuntimeError
|
||||
? fullHeight
|
||||
? 'min-h-0 flex-1'
|
||||
: 'max-h-[15lh]'
|
||||
: 'max-h-[6lh]'
|
||||
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ displayedDetailsMap[idx] }}
|
||||
{{ error.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
{{ t('g.findOnGithub') }}
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
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') }}
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
{{ t('g.getHelpAction') }}
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-full justify-center gap-2 text-xs"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
{{ t('g.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,21 +93,16 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false,
|
||||
fullHeight = false
|
||||
compact = false
|
||||
} = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
/** Allow runtime error details to fill available height (used in dedicated panel) */
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -153,8 +112,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -168,13 +125,10 @@ function handleEnterSubgraph() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = card.errors[idx]?.message
|
||||
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
|
||||
function handleCheckGithub(error: ErrorItem) {
|
||||
findOnGitHub(error.message)
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
[error.message, error.details].filter(Boolean).join('\n\n')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -40,25 +42,23 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockApplyChanges = vi.hoisted(() => vi.fn())
|
||||
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
|
||||
const mockApplyChanges = vi.fn()
|
||||
const mockIsRestarting = ref(false)
|
||||
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
||||
useApplyChanges: () => ({
|
||||
get isRestarting() {
|
||||
return mockIsRestarting.value
|
||||
},
|
||||
isRestarting: mockIsRestarting,
|
||||
applyChanges: mockApplyChanges
|
||||
})
|
||||
}))
|
||||
|
||||
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsPackInstalled = vi.fn(() => false)
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
|
||||
const mockShouldShowManagerButtons = { 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 }), i18n],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
class="flex w-full flex-1"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@@ -161,7 +161,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
class="flex w-full flex-1"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
|
||||
@@ -209,42 +209,12 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
// Find the copy button (rendered inside ErrorNodeCard)
|
||||
const copyButtons = wrapper.findAll('button')
|
||||
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
|
||||
it('renders single runtime error outside accordion in full-height panel', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'Out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Line 1', 'Line 2'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Runtime error panel title should show class type
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,31 +11,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Runtime error: full-height panel outside accordion -->
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ singleRuntimeErrorGroup?.title }}
|
||||
</div>
|
||||
<ErrorNodeCard
|
||||
:key="singleRuntimeErrorCard.id"
|
||||
:card="singleRuntimeErrorCard"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
full-height
|
||||
class="min-h-0 flex-1"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content (non-runtime or mixed errors) -->
|
||||
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<!-- Scrollable content -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -53,7 +30,6 @@
|
||||
<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)"
|
||||
@@ -210,9 +186,12 @@
|
||||
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'
|
||||
@@ -236,7 +215,6 @@ 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'
|
||||
@@ -245,7 +223,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
@@ -286,20 +264,6 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
const singleRuntimeErrorGroup = computed(() => {
|
||||
if (filteredGroups.value.length !== 1) return null
|
||||
const group = filteredGroups.value[0]
|
||||
const isSoleRuntimeError =
|
||||
group.type === 'execution' &&
|
||||
group.cards.length === 1 &&
|
||||
group.cards[0].errors.every((e) => e.isRuntimeError)
|
||||
return isSoleRuntimeError ? group : null
|
||||
})
|
||||
|
||||
const singleRuntimeErrorCard = computed(
|
||||
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
||||
)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
const downloadableModels = computed(() => {
|
||||
@@ -371,13 +335,13 @@ watch(
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
const hasMatch =
|
||||
group.type === 'execution' &&
|
||||
group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.title, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
@@ -417,4 +381,20 @@ 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>
|
||||
|
||||
@@ -48,7 +48,6 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -81,11 +80,8 @@ describe('swapNodeGroups computed', () => {
|
||||
})
|
||||
|
||||
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
useMissingNodesErrorStore().surfaceMissingNodes(
|
||||
nodeTypes,
|
||||
executionErrorStore.showErrorOverlay
|
||||
)
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes(nodeTypes)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const t = (key: string) => key
|
||||
|
||||
@@ -2,7 +2,6 @@ export interface ErrorItem {
|
||||
message: string
|
||||
details?: string
|
||||
isRuntimeError?: boolean
|
||||
exceptionType?: string
|
||||
}
|
||||
|
||||
export interface ErrorCardData {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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,7 +58,6 @@ vi.mock(
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -127,9 +126,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups non-replaceable nodes by cnrId', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
@@ -148,9 +146,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('excludes replaceable nodes from missingPackGroups', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -167,9 +164,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups nodes without cnrId under null packId', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
|
||||
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
|
||||
])
|
||||
@@ -181,9 +177,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts groups alphabetically with null packId last', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
|
||||
makeMissingNodeType('NodeB', { nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
|
||||
@@ -195,9 +190,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
|
||||
@@ -212,9 +206,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('handles string nodeType entries', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
])
|
||||
await nextTick()
|
||||
@@ -231,9 +224,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing_node group when missing nodes exist', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
@@ -245,9 +237,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes swap_nodes group when replaceable nodes exist', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -262,9 +253,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes both swap_nodes and missing_node when both exist', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -282,9 +272,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('swap_nodes has lower priority than missing_node', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -544,18 +533,13 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing node group title as message', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
|
||||
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -196,8 +195,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
searchableMessage: card.errors
|
||||
.map((e: ErrorItem) => e.message)
|
||||
.join(' '),
|
||||
searchableDetails: card.errors
|
||||
.map((e: ErrorItem) => e.details ?? '')
|
||||
.join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,7 +240,6 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
@@ -283,7 +285,7 @@ export function useErrorGroups(
|
||||
|
||||
const missingNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
@@ -393,8 +395,7 @@ export function useErrorGroups(
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type
|
||||
isRuntimeError: true
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
@@ -405,7 +406,7 @@ export function useErrorGroups(
|
||||
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||
|
||||
const pendingTypes = computed(() =>
|
||||
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(n): n is Exclude<MissingNodeType, string> =>
|
||||
typeof n !== 'string' && !n.cnrId
|
||||
)
|
||||
@@ -446,8 +447,6 @@ 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,18 +458,8 @@ 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 = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<
|
||||
string | null,
|
||||
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||
@@ -532,7 +521,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<string, SwapNodeGroup>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
@@ -556,7 +545,7 @@ export function useErrorGroups(
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = missingNodesStore.missingNodesError
|
||||
const error = executionErrorStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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'
|
||||
import { generateErrorReport } from '@/utils/errorReportUtil'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/** Fallback exception type for error reports when the backend does not provide one. Not i18n'd: used in diagnostic reports only. */
|
||||
const FALLBACK_EXCEPTION_TYPE = 'Runtime Error'
|
||||
|
||||
export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const enrichedDetails = reactive<Record<number, string>>({})
|
||||
|
||||
const displayedDetailsMap = computed(() => {
|
||||
const card = toValue(cardSource)
|
||||
return Object.fromEntries(
|
||||
card.errors.map((error, idx) => [
|
||||
idx,
|
||||
enrichedDetails[idx] ?? error.details
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
let cancelled = false
|
||||
onUnmounted(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const card = toValue(cardSource)
|
||||
const runtimeErrors = card.errors
|
||||
.map((error, idx) => ({ error, idx }))
|
||||
.filter(({ error }) => error.isRuntimeError)
|
||||
|
||||
if (runtimeErrors.length === 0) return
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
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 (!systemStatsStore.systemStats || cancelled) return
|
||||
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
|
||||
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 {
|
||||
const report = generateErrorReport({
|
||||
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
|
||||
exceptionMessage: error.message,
|
||||
traceback: error.details,
|
||||
nodeId: card.nodeId,
|
||||
nodeType: card.title,
|
||||
systemStats: systemStatsStore.systemStats,
|
||||
serverLogs: logs,
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { displayedDetailsMap }
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -79,22 +78,19 @@ function isWidgetShownOnParents(
|
||||
): boolean {
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const sourceNodeId = getSourceNodeId(widget)
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: sourceNodeId
|
||||
})
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
}
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
String(widgetNode.id),
|
||||
widget.name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getSourceNodeId,
|
||||
getWidgetName
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -88,27 +84,15 @@ const widgetsList = computed((): NodeWidgetsList => {
|
||||
const { widgets = [] } = node
|
||||
|
||||
const result: NodeWidgetsList = []
|
||||
for (const {
|
||||
sourceNodeId: entryNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
} of entries) {
|
||||
for (const { interiorNodeId, widgetName } of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
if (
|
||||
String(w.sourceNodeId) !== entryNodeId ||
|
||||
w.sourceWidgetName !== sourceWidgetName
|
||||
)
|
||||
return false
|
||||
|
||||
if (!disambiguatingSourceNodeId) return true
|
||||
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
String(w.sourceNodeId) === interiorNodeId &&
|
||||
w.sourceWidgetName === widgetName
|
||||
)
|
||||
}
|
||||
return w.name === sourceWidgetName
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
@@ -129,11 +113,12 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
})
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
@@ -95,11 +93,8 @@ describe('WidgetActions', () => {
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [200, 100]
|
||||
} as unknown as LGraphNode
|
||||
type: 'TestNode'
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
@@ -211,66 +206,4 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
||||
})
|
||||
|
||||
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = {
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
} as unknown as SubgraphNode
|
||||
const node = {
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' }
|
||||
} as unknown as LGraphNode
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
value: 'value',
|
||||
label: 'Text',
|
||||
options: {},
|
||||
y: 0,
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
} as IBaseWidget
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const wrapper = mount(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Text',
|
||||
parents: [parentSubgraphNode],
|
||||
isShownOnParents: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const hideButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Hide input'))
|
||||
expect(hideButton).toBeDefined()
|
||||
await hideButton?.trigger('click')
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
demoteWidget,
|
||||
getSourceNodeId,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -43,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -76,29 +73,26 @@ function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const disambiguatingSourceNodeId = getSourceNodeId(widget)
|
||||
|
||||
for (const parent of parents) {
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) {
|
||||
console.error('Could not resolve source widget for promoted widget')
|
||||
return
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
|
||||
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
promoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
|
||||
@@ -5,11 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
@@ -51,29 +49,19 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
if (!node) return []
|
||||
|
||||
return promotionEntries.value.flatMap(
|
||||
({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}): WidgetItem[] => {
|
||||
if (sourceNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
|
||||
({ interiorNodeId, widgetName }): WidgetItem[] => {
|
||||
if (interiorNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === widgetName)
|
||||
if (!widget) return []
|
||||
return [
|
||||
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
|
||||
]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
|
||||
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
|
||||
if (!wNode) return []
|
||||
const widget = getPromotableWidgets(wNode).find((w) => {
|
||||
if (w.name !== sourceWidgetName) return false
|
||||
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
return true
|
||||
})
|
||||
const widget = getPromotableWidgets(wNode).find(
|
||||
(w) => w.name === widgetName
|
||||
)
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
@@ -88,16 +76,11 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => {
|
||||
const sid = getSourceNodeId(w)
|
||||
return {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
...(sid && { disambiguatingSourceNodeId: sid })
|
||||
}
|
||||
})
|
||||
value.map(([n, w]) => ({
|
||||
interiorNodeId: String(n.id),
|
||||
widgetName: getWidgetName(w)
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,11 +103,12 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
if (!node) return []
|
||||
return interiorWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(w)
|
||||
})
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
w.name
|
||||
)
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -153,20 +137,8 @@ const filteredActive = computed<WidgetItem[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
|
||||
node.computeSize(node.size)
|
||||
node.setDirtyCanvas(true, true)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
? `${item[0].id}: ${item[1].name}:${sid}`
|
||||
: `${item[0].id}: ${item[1].name}`
|
||||
return `${item[0].id}: ${item[1].name}`
|
||||
}
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w) => [n, w])
|
||||
@@ -175,26 +147,49 @@ function demote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
promotionStore.demote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
)
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
promotionStore.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
}
|
||||
function showAll() {
|
||||
for (const item of filteredCandidates.value) {
|
||||
promote(item)
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of filteredCandidates.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
demote(item)
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of filteredActive.value) {
|
||||
if (String(n.id) === '-1') continue
|
||||
promotionStore.demote(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
)
|
||||
}
|
||||
}
|
||||
function showRecommended() {
|
||||
for (const item of recommendedWidgets.value) {
|
||||
promote(item)
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of recommendedWidgets.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import MediaLightbox from './MediaLightbox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
close: 'Close',
|
||||
gallery: 'Gallery',
|
||||
previous: 'Previous',
|
||||
next: 'Next'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type MockResultItem = Partial<ResultItemImpl> & {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
nodeId: NodeId
|
||||
mediaType: string
|
||||
id?: string
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
isVideo?: boolean
|
||||
isAudio?: boolean
|
||||
}
|
||||
|
||||
describe('MediaLightbox', () => {
|
||||
const mockComfyImage = {
|
||||
name: 'ComfyImage',
|
||||
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
|
||||
props: ['src', 'contain', 'alt']
|
||||
}
|
||||
|
||||
const mockResultVideo = {
|
||||
name: 'ResultVideo',
|
||||
template:
|
||||
'<div class="mock-result-video" data-testid="result-video"></div>',
|
||||
props: ['result']
|
||||
}
|
||||
|
||||
const mockResultAudio = {
|
||||
name: 'ResultAudio',
|
||||
template:
|
||||
'<div class="mock-result-audio" data-testid="result-audio"></div>',
|
||||
props: ['result']
|
||||
}
|
||||
|
||||
const mockGalleryItems: MockResultItem[] = [
|
||||
{
|
||||
filename: 'image1.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '123' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image1.jpg',
|
||||
id: '1'
|
||||
},
|
||||
{
|
||||
filename: 'image2.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '456' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image2.jpg',
|
||||
id: '2'
|
||||
},
|
||||
{
|
||||
filename: 'image3.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '789' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image3.jpg',
|
||||
id: '3'
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountGallery = (props = {}) => {
|
||||
return mount(MediaLightbox, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: {
|
||||
ComfyImage: mockComfyImage,
|
||||
ResultVideo: mockResultVideo,
|
||||
ResultAudio: mockResultAudio
|
||||
},
|
||||
stubs: {
|
||||
teleport: true
|
||||
}
|
||||
},
|
||||
props: {
|
||||
allGalleryItems: mockGalleryItems as ResultItemImpl[],
|
||||
activeIndex: 0,
|
||||
...props
|
||||
},
|
||||
attachTo: document.getElementById('app') || undefined
|
||||
})
|
||||
}
|
||||
|
||||
it('renders overlay with role="dialog" and aria-modal', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows navigation buttons when multiple items', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single item', async () => {
|
||||
const wrapper = mountGallery({
|
||||
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows gallery when activeIndex changes from -1', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: -1 })
|
||||
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update:activeIndex with -1 when close button clicked', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next item on ArrowRight', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('navigates to previous item on ArrowLeft', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 1 })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('wraps to last item on ArrowLeft from first', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
|
||||
})
|
||||
|
||||
it('closes gallery on Escape', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'Escape' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="galleryVisible"
|
||||
ref="dialogRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="$t('g.gallery')"
|
||||
tabindex="-1"
|
||||
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/90 outline-none"
|
||||
data-mask
|
||||
@mousedown="onMaskMouseDown"
|
||||
@mouseup="onMaskMouseUp"
|
||||
@keydown.stop="handleKeyDown"
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="absolute top-4 right-4 z-10 rounded-full"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="close"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</Button>
|
||||
|
||||
<!-- Previous Button -->
|
||||
<Button
|
||||
v-if="hasMultiple"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="fixed top-1/2 left-4 z-10 -translate-y-1/2 rounded-full"
|
||||
:aria-label="$t('g.previous')"
|
||||
@click="navigateImage(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-6" />
|
||||
</Button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-full max-w-full items-center justify-center">
|
||||
<template v-if="activeItem">
|
||||
<ComfyImage
|
||||
v-if="activeItem.isImage"
|
||||
:key="activeItem.url"
|
||||
:src="activeItem.url"
|
||||
:contain="false"
|
||||
:alt="activeItem.filename"
|
||||
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
|
||||
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<Button
|
||||
v-if="hasMultiple"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="fixed top-1/2 right-4 z-10 -translate-y-1/2 rounded-full"
|
||||
:aria-label="$t('g.next')"
|
||||
@click="navigateImage(1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeIndex', value: number): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
allGalleryItems: ResultItemImpl[]
|
||||
activeIndex: number
|
||||
}>()
|
||||
|
||||
const galleryVisible = ref(false)
|
||||
const dialogRef = ref<HTMLElement>()
|
||||
let previouslyFocusedElement: HTMLElement | null = null
|
||||
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
|
||||
const activeItem = computed(() => props.allGalleryItems[props.activeIndex])
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
(index) => {
|
||||
galleryVisible.value = index !== -1
|
||||
if (index !== -1) {
|
||||
previouslyFocusedElement = document.activeElement as HTMLElement | null
|
||||
void nextTick(() => dialogRef.value?.focus())
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function close() {
|
||||
galleryVisible.value = false
|
||||
emit('update:activeIndex', -1)
|
||||
previouslyFocusedElement?.focus()
|
||||
previouslyFocusedElement = null
|
||||
}
|
||||
|
||||
function navigateImage(direction: number) {
|
||||
const newIndex =
|
||||
(props.activeIndex + direction + props.allGalleryItems.length) %
|
||||
props.allGalleryItems.length
|
||||
emit('update:activeIndex', newIndex)
|
||||
}
|
||||
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
function onMaskMouseDown(event: MouseEvent) {
|
||||
maskMouseDownTarget = event.target
|
||||
}
|
||||
|
||||
function onMaskMouseUp(event: MouseEvent) {
|
||||
if (
|
||||
maskMouseDownTarget === event.target &&
|
||||
(event.target as HTMLElement)?.hasAttribute('data-mask')
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowLeft: () => navigateImage(-1),
|
||||
ArrowRight: () => navigateImage(1),
|
||||
Escape: () => close()
|
||||
}
|
||||
|
||||
const action = actions[event.key]
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
action()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
184
src/components/sidebar/tabs/queue/ResultGallery.test.ts
Normal file
184
src/components/sidebar/tabs/queue/ResultGallery.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultGallery from './ResultGallery.vue'
|
||||
|
||||
type MockResultItem = Partial<ResultItemImpl> & {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
nodeId: NodeId
|
||||
mediaType: string
|
||||
id?: string
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
isVideo?: boolean
|
||||
}
|
||||
|
||||
describe('ResultGallery', () => {
|
||||
// Mock ComfyImage and ResultVideo components
|
||||
const mockComfyImage = {
|
||||
name: 'ComfyImage',
|
||||
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
|
||||
props: ['src', 'contain', 'alt']
|
||||
}
|
||||
|
||||
const mockResultVideo = {
|
||||
name: 'ResultVideo',
|
||||
template:
|
||||
'<div class="mock-result-video" data-testid="result-video"></div>',
|
||||
props: ['result']
|
||||
}
|
||||
|
||||
// Sample gallery items - using mock instances with only required properties
|
||||
const mockGalleryItems: MockResultItem[] = [
|
||||
{
|
||||
filename: 'image1.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '123' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: 'image1.jpg',
|
||||
id: '1'
|
||||
},
|
||||
{
|
||||
filename: 'image2.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '456' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: 'image2.jpg',
|
||||
id: '2'
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
|
||||
// Create mock elements for Galleria to find
|
||||
document.body.innerHTML = `
|
||||
<div id="app"></div>
|
||||
`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any elements added to body
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountGallery = (props = {}) => {
|
||||
return mount(ResultGallery, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: {
|
||||
Galleria,
|
||||
ComfyImage: mockComfyImage,
|
||||
ResultVideo: mockResultVideo
|
||||
},
|
||||
stubs: {
|
||||
teleport: true
|
||||
}
|
||||
},
|
||||
props: {
|
||||
allGalleryItems: mockGalleryItems as ResultItemImpl[],
|
||||
activeIndex: 0,
|
||||
...props
|
||||
},
|
||||
attachTo: document.getElementById('app') || undefined
|
||||
})
|
||||
}
|
||||
|
||||
it('renders Galleria component with correct props', async () => {
|
||||
const wrapper = mountGallery()
|
||||
|
||||
await nextTick() // Wait for component to mount
|
||||
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
expect(galleria.props('value')).toEqual(mockGalleryItems)
|
||||
expect(galleria.props('showIndicators')).toBe(false)
|
||||
expect(galleria.props('showItemNavigators')).toBe(true)
|
||||
expect(galleria.props('fullScreen')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows gallery when activeIndex changes from -1', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: -1 })
|
||||
|
||||
// Initially galleryVisible should be false
|
||||
type GalleryVM = typeof wrapper.vm & {
|
||||
galleryVisible: boolean
|
||||
}
|
||||
const vm = wrapper.vm as GalleryVM
|
||||
expect(vm.galleryVisible).toBe(false)
|
||||
|
||||
// Change activeIndex
|
||||
await wrapper.setProps({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
// galleryVisible should become true
|
||||
expect(vm.galleryVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('should render the component properly', () => {
|
||||
// This is a meta-test to confirm the component mounts properly
|
||||
const wrapper = mountGallery()
|
||||
|
||||
// We can't directly test the compiled CSS, but we can verify the component renders
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
|
||||
// Verify that the Galleria component exists and is properly mounted
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ensures correct configuration for mobile viewport', async () => {
|
||||
// Mock window.matchMedia to simulate mobile viewport
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: query.includes('max-width: 768px'),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
})
|
||||
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
// Verify mobile media query is working
|
||||
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
|
||||
|
||||
// Check if the component renders with Galleria
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
|
||||
// Check that our PT props for positioning work correctly
|
||||
interface GalleriaPT {
|
||||
prevButton?: { style?: string }
|
||||
nextButton?: { style?: string }
|
||||
}
|
||||
const pt = galleria.props('pt') as GalleriaPT
|
||||
expect(pt?.prevButton?.style).toContain('position: fixed')
|
||||
expect(pt?.nextButton?.style).toContain('position: fixed')
|
||||
})
|
||||
|
||||
// Additional tests for interaction could be added once we can reliably
|
||||
// test Galleria component in fullscreen mode
|
||||
})
|
||||
151
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal file
151
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<Galleria
|
||||
v-model:visible="galleryVisible"
|
||||
:active-index="activeIndex"
|
||||
:value="allGalleryItems"
|
||||
:show-indicators="false"
|
||||
change-item-on-indicator-hover
|
||||
:show-item-navigators="hasMultiple"
|
||||
full-screen
|
||||
:circular="hasMultiple"
|
||||
:show-thumbnails="false"
|
||||
:pt="{
|
||||
mask: {
|
||||
onMousedown: onMaskMouseDown,
|
||||
onMouseup: onMaskMouseUp,
|
||||
'data-mask': true
|
||||
},
|
||||
prevButton: {
|
||||
style: 'position: fixed !important'
|
||||
},
|
||||
nextButton: {
|
||||
style: 'position: fixed !important'
|
||||
}
|
||||
}"
|
||||
@update:visible="handleVisibilityChange"
|
||||
@update:active-index="handleActiveIndexChange"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<ComfyImage
|
||||
v-if="item.isImage"
|
||||
:key="item.url"
|
||||
:src="item.url"
|
||||
:contain="false"
|
||||
:alt="item.filename"
|
||||
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<ResultVideo v-else-if="item.isVideo" :result="item" />
|
||||
<ResultAudio v-else-if="item.isAudio" :result="item" />
|
||||
</template>
|
||||
</Galleria>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const galleryVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeIndex', value: number): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
allGalleryItems: ResultItemImpl[]
|
||||
activeIndex: number
|
||||
}>()
|
||||
|
||||
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
|
||||
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
const onMaskMouseDown = (event: MouseEvent) => {
|
||||
maskMouseDownTarget = event.target
|
||||
}
|
||||
|
||||
const onMaskMouseUp = (event: MouseEvent) => {
|
||||
const maskEl = document.querySelector('[data-mask]')
|
||||
if (
|
||||
galleryVisible.value &&
|
||||
maskMouseDownTarget === event.target &&
|
||||
maskMouseDownTarget === maskEl
|
||||
) {
|
||||
galleryVisible.value = false
|
||||
handleVisibilityChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
(index) => {
|
||||
if (index !== -1) {
|
||||
galleryVisible.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleVisibilityChange = (visible: boolean) => {
|
||||
if (!visible) {
|
||||
emit('update:activeIndex', -1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActiveIndexChange = (index: number) => {
|
||||
emit('update:activeIndex', index)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!galleryVisible.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
navigateImage(-1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
navigateImage(1)
|
||||
break
|
||||
case 'Escape':
|
||||
galleryVisible.value = false
|
||||
handleVisibilityChange(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const navigateImage = (direction: number) => {
|
||||
const newIndex =
|
||||
(props.activeIndex + direction + props.allGalleryItems.length) %
|
||||
props.allGalleryItems.length
|
||||
emit('update:activeIndex', newIndex)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
|
||||
cannot use scoped style here. */
|
||||
.p-galleria-close-button {
|
||||
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Mobile/tablet specific fixes */
|
||||
@media screen and (max-width: 768px) {
|
||||
.p-galleria-prev-button,
|
||||
.p-galleria-next-button {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ContextMenuRoot :modal="false">
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
|
||||
@@ -28,9 +28,8 @@ export const buttonVariants = cva({
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
icon: 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
@@ -55,13 +54,8 @@ const variants = [
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'icon-sm',
|
||||
'icon',
|
||||
'icon-lg'
|
||||
] as const satisfies Array<ButtonVariants['size']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
>
|
||||
|
||||
export const FOR_STORIES = { variants, sizes } as const
|
||||
|
||||
@@ -315,45 +315,6 @@ 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,22 +35,10 @@ 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) {
|
||||
@@ -94,15 +82,6 @@ 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?.()) {
|
||||
@@ -112,15 +91,6 @@ 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)
|
||||
@@ -132,17 +102,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,10 +413,12 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
@@ -498,10 +500,12 @@ describe('Nested promoted widget mapping', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
@@ -519,70 +523,6 @@ describe('Nested promoted widget mapping', () => {
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
||||
const graph = outerSubgraphNode.graph as LGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'text'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
@@ -225,21 +224,19 @@ function safeWidgetMapper(
|
||||
function resolvePromotedSourceByInputName(inputName: string): {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
} | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
@@ -253,8 +250,7 @@ function safeWidgetMapper(
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
@@ -301,8 +297,7 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -315,11 +310,7 @@ function safeWidgetMapper(
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
@@ -46,10 +46,6 @@ function createSubgraphNode(): SubgraphNode {
|
||||
return node
|
||||
}
|
||||
|
||||
function createRegularNode(): LGraphNode {
|
||||
return new LGraphNode('testnode')
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -91,16 +87,4 @@ describe('useSubgraphOperations', () => {
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
|
||||
mocks.selectedItems = [createRegularNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,19 +5,6 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
export function extractWidgetStringValue(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'filename' in value &&
|
||||
typeof value.filename === 'string'
|
||||
)
|
||||
return value.filename
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Private image utility functions
|
||||
interface ImageLayerFilenames {
|
||||
@@ -97,23 +84,62 @@ export function useMaskEditorLoader() {
|
||||
|
||||
let nodeImageRef = parseImageRef(nodeImageUrl)
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const widgetFilename = imageWidget
|
||||
? extractWidgetStringValue(imageWidget.value)
|
||||
: undefined
|
||||
let widgetFilename: string | undefined
|
||||
if (node.widgets) {
|
||||
const imageWidget = node.widgets.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
if (typeof imageWidget.value === 'string') {
|
||||
widgetFilename = imageWidget.value
|
||||
} else if (
|
||||
typeof imageWidget.value === 'object' &&
|
||||
imageWidget.value &&
|
||||
'filename' in imageWidget.value &&
|
||||
typeof imageWidget.value.filename === 'string'
|
||||
) {
|
||||
widgetFilename = imageWidget.value.filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a widget filename, we should prioritize it over the node image
|
||||
// because the node image might be stale (e.g. from a previous save)
|
||||
// while the widget value reflects the current selection.
|
||||
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
|
||||
if (widgetFilename && !widgetFilename.startsWith('$')) {
|
||||
const parsed = parseImageWidgetValue(widgetFilename)
|
||||
nodeImageRef = {
|
||||
filename: parsed.filename,
|
||||
type: parsed.type || 'input',
|
||||
subfolder: parsed.subfolder || undefined
|
||||
try {
|
||||
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
|
||||
let filename = widgetFilename
|
||||
let subfolder: string | undefined = undefined
|
||||
let type: string | undefined = 'input' // Default to input for widget values
|
||||
|
||||
// Check for type in brackets at the end
|
||||
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
|
||||
if (typeMatch) {
|
||||
type = typeMatch[1]
|
||||
filename = filename.substring(
|
||||
0,
|
||||
filename.length - typeMatch[0].length
|
||||
)
|
||||
}
|
||||
|
||||
// Check for subfolder (forward slash separator)
|
||||
const lastSlashIndex = filename.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1) {
|
||||
subfolder = filename.substring(0, lastSlashIndex)
|
||||
filename = filename.substring(lastSlashIndex + 1)
|
||||
}
|
||||
|
||||
nodeImageRef = {
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}
|
||||
|
||||
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
|
||||
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse widget filename as ref', e)
|
||||
}
|
||||
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
|
||||
}
|
||||
|
||||
const fileToQuery = widgetFilename || nodeImageRef.filename
|
||||
|
||||
@@ -11,7 +11,6 @@ interface DragAndDropOptions<T> {
|
||||
|
||||
/**
|
||||
* Adds drag and drop file handling to a node
|
||||
* Will also resolve 'text/uri-list' to a file before passing
|
||||
*/
|
||||
export const useNodeDragAndDrop = <T>(
|
||||
node: LGraphNode,
|
||||
@@ -22,55 +21,27 @@ export const useNodeDragAndDrop = <T>(
|
||||
const hasFiles = (items: DataTransferItemList) =>
|
||||
!!Array.from(items).find((f) => f.kind === 'file')
|
||||
|
||||
const filterFiles = (files: FileList | File[]) =>
|
||||
Array.from(files).filter(fileFilter)
|
||||
const filterFiles = (files: FileList) => Array.from(files).filter(fileFilter)
|
||||
|
||||
const hasValidFiles = (files: FileList) => filterFiles(files).length > 0
|
||||
|
||||
const isDraggingFiles = (e: DragEvent | undefined) => {
|
||||
if (!e?.dataTransfer?.items) return false
|
||||
return (
|
||||
onDragOver?.(e) ??
|
||||
(hasFiles(e.dataTransfer.items) ||
|
||||
e?.dataTransfer?.types?.includes('text/uri-list'))
|
||||
)
|
||||
return onDragOver?.(e) ?? hasFiles(e.dataTransfer.items)
|
||||
}
|
||||
|
||||
const isDraggingValidFiles = (e: DragEvent | undefined) => {
|
||||
if (e?.dataTransfer?.files?.length)
|
||||
return hasValidFiles(e.dataTransfer.files)
|
||||
|
||||
return !!e?.dataTransfer?.getData('text/uri-list')
|
||||
if (!e?.dataTransfer?.files) return false
|
||||
return hasValidFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
node.onDragOver = isDraggingFiles
|
||||
|
||||
node.onDragDrop = async function (e: DragEvent) {
|
||||
node.onDragDrop = function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
if (files.length) {
|
||||
await onDrop(files)
|
||||
return true
|
||||
}
|
||||
|
||||
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
|
||||
if (!uri || uri.origin !== location.origin) return false
|
||||
|
||||
try {
|
||||
const resp = await fetch(uri)
|
||||
const fileName = uri?.searchParams?.get('filename')
|
||||
if (!fileName || !resp.ok) return false
|
||||
|
||||
const blob = await resp.blob()
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
const uriFiles = filterFiles([file])
|
||||
if (!uriFiles.length) return false
|
||||
|
||||
await onDrop(uriFiles)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
void onDrop(files)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
'10',
|
||||
'seed'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -125,7 +126,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
@@ -135,8 +137,8 @@ describe(usePromotedPreviews, () => {
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
@@ -149,7 +151,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -165,7 +168,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -188,12 +192,14 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'20',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
@@ -215,7 +221,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
@@ -225,8 +232,8 @@ describe(usePromotedPreviews, () => {
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -239,7 +246,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -251,8 +259,8 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -265,7 +273,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -277,7 +286,8 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'99',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -290,12 +300,14 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
'10',
|
||||
'seed'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
|
||||
@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
@@ -30,15 +30,13 @@ export function usePromotedPreviews(
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
e.sourceWidgetName.startsWith('$$')
|
||||
)
|
||||
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
|
||||
if (!pseudoEntries.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
|
||||
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
@@ -47,7 +45,7 @@ export function usePromotedPreviews(
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.sourceNodeId
|
||||
entry.interiorNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
@@ -65,8 +63,8 @@ export function usePromotedPreviews(
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
interiorNodeId: entry.interiorNodeId,
|
||||
widgetName: entry.widgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
|
||||
@@ -2,28 +2,15 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
export type ResolvedPromotedWidget = {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
/**
|
||||
* The original leaf-level source node ID, used to distinguish promoted
|
||||
* widgets with the same name on the same intermediate node. Unlike
|
||||
* `sourceNodeId` (the direct interior node), this traces to the deepest
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState,
|
||||
@@ -79,9 +78,9 @@ function setPromotions(
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
entries.map(([sourceNodeId, sourceWidgetName]) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
entries.map(([interiorNodeId, widgetName]) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -115,21 +114,6 @@ describe(createPromotedWidgetView, () => {
|
||||
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
|
||||
expect(view.sourceNodeId).toBe('42')
|
||||
expect(view.sourceWidgetName).toBe('myWidget')
|
||||
expect(view.disambiguatingSourceNodeId).toBeUndefined()
|
||||
})
|
||||
|
||||
test('exposes disambiguatingSourceNodeId when provided', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'42',
|
||||
'myWidget',
|
||||
undefined,
|
||||
'99'
|
||||
)
|
||||
expect(view.sourceNodeId).toBe('42')
|
||||
expect(view.sourceWidgetName).toBe('myWidget')
|
||||
expect(view.disambiguatingSourceNodeId).toBe('99')
|
||||
})
|
||||
|
||||
test('name defaults to widgetName when no displayName given', () => {
|
||||
@@ -470,8 +454,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'picker'
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'picker'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -513,8 +497,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
expect(promotions).toHaveLength(1)
|
||||
expect(promotions[0]).toStrictEqual({
|
||||
sourceNodeId: String(secondNode.id),
|
||||
sourceWidgetName: 'picker'
|
||||
interiorNodeId: String(secondNode.id),
|
||||
widgetName: 'picker'
|
||||
})
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('b')
|
||||
@@ -607,14 +591,18 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(promotedNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(linkedNodeA.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -663,10 +651,12 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
@@ -743,8 +733,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -774,8 +764,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -833,8 +823,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
|
||||
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -878,8 +868,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: linkedEntry[0],
|
||||
sourceWidgetName: linkedEntry[1]
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -935,8 +925,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: String(activeAliasNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1123,8 +1113,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: String(innerNodes[0].id),
|
||||
sourceWidgetName: 'stringWidget'
|
||||
interiorNodeId: String(innerNodes[0].id),
|
||||
widgetName: 'stringWidget'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1152,8 +1142,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredEntries).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1290,8 +1280,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'string_a' },
|
||||
{ sourceNodeId: '19', sourceWidgetName: 'string_a' }
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
@@ -1426,8 +1416,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(hydratedEntries).toStrictEqual([
|
||||
{
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1445,12 +1435,12 @@ describe('widgets getter caching', () => {
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
@@ -1475,12 +1465,12 @@ describe('widgets getter caching', () => {
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
@@ -1648,7 +1638,7 @@ describe('promote/demote cycle', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1719,197 +1709,6 @@ describe('disconnected state', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function createThreeLevelNestedSubgraph() {
|
||||
// Level C (innermost): concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'c_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('c_input', '*')
|
||||
const concreteWidget = concreteNode.addWidget(
|
||||
'number',
|
||||
'c_input',
|
||||
100,
|
||||
() => {}
|
||||
)
|
||||
concreteInput.widget = { name: 'c_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 501 })
|
||||
|
||||
// Level B (middle): wraps C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'b_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 502 })
|
||||
|
||||
// Level A (outermost): wraps B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'a_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 503 })
|
||||
return { concreteNode, concreteWidget, subgraphNodeA }
|
||||
}
|
||||
|
||||
describe('three-level nested value propagation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('value set at outermost level propagates to concrete widget', () => {
|
||||
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(100)
|
||||
|
||||
subgraphNodeA.widgets[0].value = 200
|
||||
expect(concreteNode.widgets![0].value).toBe(200)
|
||||
})
|
||||
|
||||
test('type resolves correctly through all three layers', () => {
|
||||
const { subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
})
|
||||
|
||||
test('concrete value change is visible at the outermost level', () => {
|
||||
const { concreteWidget, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
concreteWidget.value = 999
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(999)
|
||||
})
|
||||
|
||||
test('nested duplicate-name promotions resolve and update independently by disambiguating source node id', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => {})
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => {})
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
||||
id: 4,
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(outerSubgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(
|
||||
widgets.map((widget) => widget.disambiguatingSourceNodeId)
|
||||
).toStrictEqual([String(firstTextNode.id), String(secondTextNode.id)])
|
||||
expect(widgets.map((widget) => widget.value)).toStrictEqual([
|
||||
'11111111111',
|
||||
'22222222222'
|
||||
])
|
||||
|
||||
widgets[1].value = 'updated-second'
|
||||
|
||||
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
|
||||
expect(widgets[0].value).toBe('11111111111')
|
||||
expect(widgets[1].value).toBe('updated-second')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-link representative determinism for input-based promotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('first link is consistently chosen as representative for reads and writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('shared', '*')
|
||||
firstNode.addWidget('text', 'shared', 'first-val', () => {})
|
||||
firstInput.widget = { name: 'shared' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('shared', '*')
|
||||
secondNode.addWidget('text', 'shared', 'second-val', () => {})
|
||||
secondInput.widget = { name: 'shared' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const thirdNode = new LGraphNode('ThirdNode')
|
||||
const thirdInput = thirdNode.addInput('shared', '*')
|
||||
thirdNode.addWidget('text', 'shared', 'third-val', () => {})
|
||||
thirdInput.widget = { name: 'shared' }
|
||||
subgraph.add(thirdNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[0].connect(secondInput, secondNode)
|
||||
subgraph.inputNode.slots[0].connect(thirdInput, thirdNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(1)
|
||||
expect(widgets[0].sourceNodeId).toBe(String(firstNode.id))
|
||||
|
||||
// Read returns the first link's value
|
||||
expect(widgets[0].value).toBe('first-val')
|
||||
|
||||
// Write propagates to all linked nodes
|
||||
widgets[0].value = 'updated'
|
||||
expect(firstNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('updated')
|
||||
expect(thirdNode.widgets![0].value).toBe('updated')
|
||||
|
||||
// Repeated reads are still deterministic
|
||||
expect(widgets[0].value).toBe('updated')
|
||||
})
|
||||
})
|
||||
|
||||
function createFakeCanvasContext() {
|
||||
return new Proxy({} as CanvasRenderingContext2D, {
|
||||
get: () => vi.fn(() => ({ width: 10 }))
|
||||
@@ -2258,12 +2057,12 @@ describe('promoted combo rendering', () => {
|
||||
)
|
||||
|
||||
expect(promotions).toContainEqual({
|
||||
sourceNodeId: String(subgraphNodeA.id),
|
||||
sourceWidgetName: 'lora_name'
|
||||
interiorNodeId: String(subgraphNodeA.id),
|
||||
widgetName: 'lora_name'
|
||||
})
|
||||
expect(promotions).not.toContainEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'lora_name'
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'lora_name'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,16 +49,9 @@ export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
displayName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
@@ -87,8 +80,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
private readonly displayName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
@@ -295,8 +287,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -310,8 +301,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
@@ -351,9 +341,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName &&
|
||||
boundWidget.disambiguatingSourceNodeId ===
|
||||
this.disambiguatingSourceNodeId
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
@@ -119,12 +118,9 @@ describe('pruneDisconnected', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
},
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
|
||||
{ interiorNodeId: '9999', widgetName: 'missing-node' }
|
||||
])
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -133,9 +129,7 @@ describe('pruneDisconnected', () => {
|
||||
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
|
||||
])
|
||||
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -149,8 +143,8 @@ describe('pruneDisconnected', () => {
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
|
||||
@@ -160,8 +154,8 @@ describe('pruneDisconnected', () => {
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -261,10 +255,12 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -284,52 +280,12 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all enabled widgets are already promoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores computed-disabled widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
widget.computedDisabled = true
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
@@ -27,30 +26,6 @@ export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
}
|
||||
|
||||
function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
|
||||
for (const parent of parents) {
|
||||
parent.computeSize(parent.size)
|
||||
parent.setDirtyCanvas(true, true)
|
||||
}
|
||||
useCanvasStore().canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
@@ -76,14 +51,16 @@ export function promoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, source)
|
||||
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
|
||||
message: `Promoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
@@ -94,14 +71,16 @@ export function demoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, source)
|
||||
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
|
||||
message: `Demoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
@@ -131,9 +110,10 @@ export function addWidgetPromotionOptions(
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const parents = getParentNodes()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
@@ -167,9 +147,10 @@ export function tryToggleWidgetPromotion() {
|
||||
const parents = getParentNodes()
|
||||
if (!parents.length || !widget) return
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
@@ -238,10 +219,12 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const widget = node.widgets?.find(isPreviewPseudoWidget)
|
||||
if (!widget) return
|
||||
if (
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
@@ -259,18 +242,20 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
// includes this node and onDrawBackground can call updatePreviews on it
|
||||
// once execution outputs arrive.
|
||||
if (supportsVirtualCanvasImagePreview(node)) {
|
||||
const canvasSource: PromotedWidgetSource = {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
if (
|
||||
!store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
canvasSource
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
) {
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -286,7 +271,8 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
toPromotionSource(n, w)
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
)
|
||||
}
|
||||
subgraphNode.computeSize(subgraphNode.size)
|
||||
@@ -299,16 +285,17 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
|
||||
[]
|
||||
|
||||
const validEntries = entries.filter((entry) => {
|
||||
const node = subgraph.getNodeById(entry.sourceNodeId)
|
||||
const node = subgraph.getNodeById(entry.interiorNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(entry)
|
||||
return false
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === entry.sourceWidgetName
|
||||
(iw) => iw.name === entry.widgetName
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(entry)
|
||||
@@ -328,26 +315,9 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
refreshPromotedWidgetRendering([subgraphNode])
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ type PromotedWidgetStub = Pick<
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
@@ -52,8 +51,7 @@ function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode,
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
@@ -63,7 +61,6 @@ function createPromotedWidget(
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
@@ -97,27 +94,6 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
|
||||
const host = createHostNode(100)
|
||||
const sourceNode = addNodeToHost(host, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
|
||||
]
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(sourceNode.id),
|
||||
'text',
|
||||
'2'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
|
||||
@@ -20,8 +20,7 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
|
||||
function traversePromotedWidgetChain(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
const visited = new Set<string>()
|
||||
const hostUidByObject = new WeakMap<SubgraphNode, number>()
|
||||
@@ -29,7 +28,6 @@ function traversePromotedWidgetChain(
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
let currentSourceNodeId = sourceNodeId
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
let hostUid = hostUidByObject.get(currentHost)
|
||||
@@ -39,7 +37,7 @@ function traversePromotedWidgetChain(
|
||||
hostUidByObject.set(currentHost, hostUid)
|
||||
}
|
||||
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
|
||||
if (visited.has(key)) {
|
||||
return { status: 'failure', failure: 'cycle' }
|
||||
}
|
||||
@@ -50,10 +48,8 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
sourceNode.widgets,
|
||||
currentWidgetName,
|
||||
currentSourceNodeId
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
@@ -73,42 +69,22 @@ function traversePromotedWidgetChain(
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
currentSourceNodeId = undefined
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
function findWidgetByIdentity(
|
||||
widgets: IBaseWidget[] | undefined,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
if (!widgets) return undefined
|
||||
|
||||
if (sourceNodeId) {
|
||||
return widgets.find(
|
||||
(entry) =>
|
||||
isPromotedWidgetView(entry) &&
|
||||
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
|
||||
sourceNodeId &&
|
||||
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
return widgets.find((entry) => entry.name === widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
|
||||
const widget = node.widgets?.find(
|
||||
(entry: IBaseWidget) => entry.name === widgetName
|
||||
)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
@@ -117,11 +93,10 @@ export function resolvePromotedWidgetAtHost(
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
if (!hostNode.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'invalid-host' }
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ export function resolvePromotedWidgetSource(
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName,
|
||||
widget.disambiguatingSourceNodeId
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
|
||||
@@ -161,33 +161,4 @@ describe('resolveSubgraphInputLink', () => {
|
||||
expect(result).toBe('ok')
|
||||
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('returns first link result with 3+ links connected', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'third_input', 'thirdWidget')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
({ targetInput }) => targetInput.name
|
||||
)
|
||||
|
||||
expect(result).toBe('first_input')
|
||||
})
|
||||
|
||||
test('returns undefined when all links fail to resolve', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
() => undefined
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,93 +158,4 @@ describe('resolveSubgraphInputTarget', () => {
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget and non-widget inputs on the same nested SubgraphNode independently', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'width', type: '*' },
|
||||
{ name: 'audio', type: '*' }
|
||||
]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 820
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const widthInput = innerSubgraphNode.addInput('width', '*')
|
||||
innerSubgraphNode.addWidget('number', 'width', 0, () => undefined)
|
||||
widthInput.widget = { name: 'width' }
|
||||
|
||||
const audioInput = innerSubgraphNode.addInput('audio', '*')
|
||||
|
||||
const widthSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'width'
|
||||
)!
|
||||
const audioSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'audio'
|
||||
)!
|
||||
widthSlot.connect(widthInput, innerSubgraphNode)
|
||||
audioSlot.connect(audioInput, innerSubgraphNode)
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '820',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('three-level nesting returns immediate child target, not deepest', () => {
|
||||
// outer → middle → inner (concrete)
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
concreteNode.id = 900
|
||||
const concreteInput = concreteNode.addInput('seed_input', '*')
|
||||
concreteNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
concreteInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(concreteNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 901
|
||||
})
|
||||
middleSubgraph.add(innerSubgraphNode)
|
||||
const middleInput = innerSubgraphNode.addInput('seed', '*')
|
||||
innerSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
middleInput.widget = { name: 'seed' }
|
||||
middleSubgraph.inputNode.slots[0].connect(middleInput, innerSubgraphNode)
|
||||
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 902
|
||||
})
|
||||
outerSubgraph.add(middleSubgraphNode)
|
||||
const outerInput = middleSubgraphNode.addInput('seed', '*')
|
||||
middleSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
outerInput.widget = { name: 'seed' }
|
||||
outerSubgraph.inputNode.slots[0].connect(outerInput, middleSubgraphNode)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
// Should return the immediate child (middle), not the deepest (concrete)
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '902',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
|
||||
|
||||
type ResolvedSubgraphInputTarget = {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId?: string
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputTarget(
|
||||
@@ -21,16 +19,6 @@ export function resolveSubgraphInputTarget(
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.sourceWidgetName,
|
||||
sourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
@@ -61,7 +61,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
@@ -72,8 +72,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
|
||||
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' },
|
||||
{ interiorNodeId: innerIds[1], widgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
@@ -88,8 +88,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
@@ -97,8 +97,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' }
|
||||
])
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
@@ -109,7 +109,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
@@ -124,7 +124,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
@@ -143,7 +143,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
@@ -164,20 +164,24 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
innerIds[0],
|
||||
'stringWidget'
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
innerIds[0],
|
||||
'stringWidget'
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
@@ -185,7 +189,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -195,8 +199,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
@@ -207,12 +211,10 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
).toStrictEqual([{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list', () => {
|
||||
test('removeWidgetByName removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
@@ -220,13 +222,12 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
|
||||
subgraphNode.removeWidget(widgetA)
|
||||
subgraphNode.removeWidgetByName('widgetA')
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
@@ -238,7 +239,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
@@ -259,7 +260,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
@@ -278,8 +279,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
@@ -290,119 +291,4 @@ describe('Subgraph proxyWidgets', () => {
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
subgraphNode.graph!.add(subgraphNode)
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
const inputA = nodeA.addInput('shared_input', '*')
|
||||
nodeA.addWidget('text', 'shared_input', 'first', () => {})
|
||||
inputA.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
const inputB = nodeB.addInput('shared_input', '*')
|
||||
nodeB.addWidget('text', 'shared_input', 'second', () => {})
|
||||
inputB.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeB)
|
||||
|
||||
const nodeC = new LGraphNode('NodeC')
|
||||
const inputC = nodeC.addInput('shared_input', '*')
|
||||
nodeC.addWidget('text', 'shared_input', 'third', () => {})
|
||||
inputC.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeC)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(inputA, nodeA)
|
||||
subgraph.inputNode.slots[0].connect(inputB, nodeB)
|
||||
subgraph.inputNode.slots[0].connect(inputC, nodeC)
|
||||
|
||||
const firstRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const secondRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const thirdRead = subgraphNode.widgets.map((w) => w.value)
|
||||
|
||||
expect(firstRead).toStrictEqual(secondRead)
|
||||
expect(secondRead).toStrictEqual(thirdRead)
|
||||
expect(subgraphNode.widgets[0].value).toBe('first')
|
||||
})
|
||||
|
||||
test('3-level nested promotion resolves concrete widget type and value', () => {
|
||||
usePromotionStore()
|
||||
|
||||
// Level C: innermost subgraph with a concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'deep_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('deep_input', '*')
|
||||
concreteNode.addWidget('number', 'deep_input', 42, () => {})
|
||||
concreteInput.widget = { name: 'deep_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
|
||||
|
||||
// Level B: middle subgraph containing C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'mid_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
|
||||
|
||||
// Level A: outermost subgraph containing B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'outer_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
|
||||
|
||||
// Outermost promoted widget should resolve through all 3 levels
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
// Remove: should clean up store AND input reference
|
||||
subgraphNode.removeWidget(view)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(0)
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote: should work correctly after cleanup
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
56
src/core/graph/subgraph/unpromotedWidgetUtils.test.ts
Normal file
56
src/core/graph/subgraph/unpromotedWidgetUtils.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all enabled widgets are already promoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(interiorNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores computed-disabled widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
widget.computedDisabled = true
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
20
src/core/graph/subgraph/unpromotedWidgetUtils.ts
Normal file
20
src/core/graph/subgraph/unpromotedWidgetUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!promotionStore.isPromoted(
|
||||
rootGraph.id,
|
||||
subgraphNodeId,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user