Files
MajoorWaldi-ComfyUI-Majoor-…/ui/tests/workflow_graph_map_data.vitest.ts

587 lines
22 KiB
TypeScript

// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
findWorkflowNode,
getNodeDisplayName,
getNodeInputSlotNames,
getNodeParamEntries,
getNodeTypeLabel,
getNodeWidgetValueEntries,
getWorkflowNodes,
resolveAssetWorkflow,
} from "../features/viewer/workflowGraphMap/workflowGraphMapData.js";
import {
expandSubgraphsForMinimap,
synthesizeWorkflowFromPromptGraph,
} from "../components/sidebar/utils/minimap.js";
describe("workflow graph map data", () => {
it("maps widgets_values to Comfy widget inputs without connected socket offset", () => {
const node = {
id: 104,
type: "WanMoekSamplerAdvanced",
inputs: [
{ name: "model_high_noise", link: 1 },
{ name: "model_low_noise", link: 2 },
{ name: "positive", link: 3 },
{ name: "negative", link: 4 },
{ name: "latent_image", link: 5 },
{ name: "boundary", widget: true },
{ name: "add_noise", widget: true },
{ name: "noise_seed", widget: true },
{ name: "control_after_generate", widget: true },
{ name: "steps", widget: true },
],
widgets_values: [0.9, "enable", 131289120546304, "randomize", 20],
};
expect(getNodeInputSlotNames(node)).toEqual([
"boundary",
"add_noise",
"noise_seed",
"control_after_generate",
"steps",
]);
expect(getNodeParamEntries(node).slice(0, 5)).toEqual([
["boundary", 0.9],
["add_noise", "enable"],
["noise_seed", 131289120546304],
["control_after_generate", "randomize"],
["steps", 20],
]);
});
it("uses Comfy widget object metadata and skips linked non-widget inputs", () => {
const node = {
id: 7,
type: "CustomSampler",
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "latent", type: "LATENT", link: 2 },
{ label: "scheduler mode", name: "scheduler", type: "COMBO", widget: { name: "scheduler" } },
{ label: "custom strength", name: "strength", type: "FLOAT", widget: { name: "strength" } },
],
widgets_values: ["karras", 0.75],
};
expect(getNodeInputSlotNames(node)).toEqual(["scheduler mode", "custom strength"]);
expect(getNodeWidgetValueEntries(node)).toEqual([
{ label: "scheduler mode", value: "karras", index: 0 },
{ label: "custom strength", value: 0.75, index: 1 },
]);
});
it("keeps linked widget labels and trailing unlinked widget inputs in widgets_values order", () => {
const node = {
id: 120,
type: "WanFirstLastFrameToVideo",
inputs: [
{ name: "positive", type: "CONDITIONING", link: 20 },
{ name: "negative", type: "CONDITIONING", link: 21 },
{ name: "vae", type: "VAE", link: 22 },
{ name: "width", type: "INT", link: 23, widget: { name: "width" } },
{ name: "height", type: "INT", link: 24, widget: { name: "height" } },
{ name: "length", type: "INT", link: 25, widget: { name: "length" } },
{ name: "batch_size", type: "INT", widget: { name: "batch_size" } },
],
widgets_values: [832, 480, 81, 1],
};
expect(getNodeInputSlotNames(node)).toEqual(["width", "height", "length", "batch_size"]);
expect(getNodeParamEntries(node).slice(0, 4)).toEqual([
["width", 832],
["height", 480],
["length", 81],
["batch_size", 1],
]);
});
it("does not infer unmarked socket inputs as widgets for core sampler nodes", () => {
const node = {
id: 3,
type: "KSampler",
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 2 },
{ name: "negative", type: "CONDITIONING", link: 3 },
{ name: "latent_image", type: "LATENT", link: 4 },
{ name: "seed", type: "INT" },
{ name: "steps", type: "INT" },
{ name: "cfg", type: "FLOAT" },
{ name: "sampler_name", type: "COMBO" },
{ name: "scheduler", type: "COMBO" },
{ name: "denoise", type: "FLOAT" },
],
widgets_values: [1088132929177955, "randomize", 20, 8, "euler", "simple", 1],
};
expect(getNodeInputSlotNames(node)).toEqual([]);
expect(getNodeWidgetValueEntries(node)).toEqual([
{ label: "seed", value: 1088132929177955, index: 0 },
{ label: "control_after_generate", value: "randomize", index: 1 },
{ label: "steps", value: 20, index: 2 },
{ label: "cfg", value: 8, index: 3 },
{ label: "sampler_name", value: "euler", index: 4 },
{ label: "scheduler", value: "simple", index: 5 },
{ label: "denoise", value: 1, index: 6 },
]);
expect(getNodeParamEntries(node)).toEqual([
["seed", 1088132929177955],
["control_after_generate", "randomize"],
["steps", 20],
["cfg", 8],
["sampler_name", "euler"],
["scheduler", "simple"],
["denoise", 1],
]);
});
it("uses prompt inputs for subgraph sampler parameters when widgets_values are ambiguous", () => {
const workflow = resolveAssetWorkflow({
workflow: {
nodes: [
{
id: 95,
type: "c20beb1e-2a45-4872-913e-21018c09c578",
properties: {
proxyWidgets: [
["3", "seed"],
["3", "steps"],
],
},
},
],
definitions: {
subgraphs: [
{
id: "c20beb1e-2a45-4872-913e-21018c09c578",
nodes: [
{
id: 3,
type: "KSampler",
inputs: [
{ name: "model", type: "MODEL", link: 13 },
{ name: "positive", type: "CONDITIONING", link: 30 },
{ name: "negative", type: "CONDITIONING", link: 33 },
{ name: "latent_image", type: "LATENT", link: 427 },
{ name: "seed", type: "INT", link: 71, widget: { name: "seed" } },
{ name: "steps", type: "INT", link: 72, widget: { name: "steps" } },
],
widgets_values: [
1088132929177955,
"randomize",
8,
1,
"res_multistep",
"simple",
1,
],
},
],
},
],
},
},
prompt: {
"95:3": {
class_type: "KSampler",
inputs: {
seed: 1088132929177955,
steps: 8,
cfg: 1,
sampler_name: "res_multistep",
scheduler: "simple",
denoise: 1,
model: ["95:11", 0],
positive: ["95:27", 0],
negative: ["95:33", 0],
latent_image: ["95:242", 0],
},
},
},
});
const sampler = findWorkflowNode(workflow, "95::3");
expect(getNodeParamEntries(sampler)).toEqual([
["seed", 1088132929177955],
["steps", 8],
["cfg", 1],
["sampler_name", "res_multistep"],
["scheduler", "simple"],
["denoise", 1],
]);
});
it("supports custom nodes that save widgets_values as a dictionary", () => {
expect(
getNodeWidgetValueEntries({
type: "CustomNode",
widgets_values: {
mode: "fast",
amount: 12,
},
}),
).toEqual([
{ label: "mode", value: "fast", index: 0 },
{ label: "amount", value: 12, index: 1 },
]);
});
it("extracts primitive and object params from synthesized prompt graphs", () => {
const workflow = resolveAssetWorkflow({
prompt: {
85: {
class_type: "Power Lora Loader (rgthree)",
_meta: { title: "Power Lora Loader" },
inputs: {
model: ["140", 0],
clip: ["83", 0],
text: "cinematic portrait",
lora_2: {
on: true,
lora: "detailer.safetensors",
strength: 0.5,
},
},
},
},
});
const node = workflow.nodes.find((item) => String(item.id) === "85");
expect(node.title).toBe("Power Lora Loader");
expect(getNodeParamEntries(node)).toEqual([
["text", "cinematic portrait"],
[
"lora_2",
{
on: true,
lora: "detailer.safetensors",
strength: 0.5,
},
],
]);
});
it("treats colon-prefixed subgraph prompt references as links", () => {
const prompt = {
75: {
inputs: {
filename_prefix: "video/LTX_2.3_i2v",
video: ["320:310", 0],
},
class_type: "SaveVideo",
_meta: { title: "Save Video" },
},
"320:310": {
inputs: {
fps: ["320:298", 0],
images: ["320:315", 0],
audio: ["320:297", 0],
},
class_type: "CreateVideo",
_meta: { title: "Create Video" },
},
"320:298": {
inputs: { expression: "a", "values.a": ["320:300", 0] },
class_type: "ComfyMathExpression",
},
"320:300": {
inputs: { value: 25 },
class_type: "PrimitiveInt",
},
};
const workflow = synthesizeWorkflowFromPromptGraph(prompt);
const saveNode = workflow.nodes.find((node) => String(node.id) === "75");
const createNode = workflow.nodes.find((node) => String(node.id) === "320:310");
expect(workflow.links).toEqual(
expect.arrayContaining([
expect.arrayContaining(["320:310", 75]),
expect.arrayContaining(["320:298", "320:310"]),
]),
);
expect(getNodeParamEntries(saveNode)).toEqual([["filename_prefix", "video/LTX_2.3_i2v"]]);
expect(getNodeParamEntries(createNode)).toEqual([]);
});
it("uses readable names for opaque subgraph node types", () => {
const workflow = resolveAssetWorkflow({
workflow: {
nodes: [
{
id: 20,
type: "12345678-1234-1234-1234-123456789abc",
properties: {},
},
],
definitions: {
subgraphs: [
{
id: "12345678-1234-1234-1234-123456789abc",
name: "Face Detailer",
nodes: [],
},
],
},
},
});
const node = workflow.nodes[0];
expect(node.properties.subgraph_name).toBe("Face Detailer");
expect(getNodeDisplayName(node)).toBe("Face Detailer");
expect(getNodeTypeLabel(node)).toBe("Subgraph");
});
it("resolves official workflow template wrappers", () => {
const workflow = resolveAssetWorkflow({
template: {
nodes: [
{
id: 11,
type: "CLIPTextEncode",
title: "Positive Prompt",
inputs: [{ name: "text", type: "STRING", widget: { name: "text" } }],
widgets_values: ["cinematic template prompt"],
},
],
links: [],
},
});
expect(workflow.nodes).toHaveLength(1);
expect(getNodeParamEntries(workflow.nodes[0])).toEqual([["text", "cinematic template prompt"]]);
});
it("uses rootGraph subgraph definitions for saved Comfy workflows", () => {
const workflow = resolveAssetWorkflow({
workflow: {
nodes: [{ id: 30, type: "root-sg", properties: {} }],
rootGraph: {
subgraphs: [
{
id: "root-sg",
name: "Root Graph Detailer",
nodes: [{ id: 1, type: "InnerDetailer", title: "Inner Detailer" }],
links: [],
},
],
},
},
});
expect(workflow.nodes[0].properties.subgraph_name).toBe("Root Graph Detailer");
expect(getWorkflowNodes(workflow).map((node: any) => String(node.id))).toEqual(["30", "1"]);
expect(getNodeDisplayName(workflow.nodes[0])).toBe("Root Graph Detailer");
});
it("exposes subgraph proxy widget values on the parent node", () => {
const workflow = resolveAssetWorkflow({
workflow: {
nodes: [
{
id: 320,
type: "2454ad83-157c-40dd-9f19-5daaf4041ce0",
properties: {
proxyWidgets: [
["312", "value"],
["299", "value"],
["317", "text_encoder"],
],
},
},
],
definitions: {
subgraphs: [
{
id: "2454ad83-157c-40dd-9f19-5daaf4041ce0",
name: "Image to Video (LTX-2.3)",
inputs: [
{ name: "value_2", label: "width" },
{ name: "value_3", label: "height" },
{ name: "text_encoder" },
],
nodes: [
{
id: 312,
type: "PrimitiveInt",
title: "Width",
inputs: [],
widgets_values: [1024],
},
{
id: 299,
type: "PrimitiveInt",
title: "Height",
inputs: [],
widgets_values: [768],
},
{
id: 317,
type: "LTXAVTextEncoderLoader",
title: "Text Encoder Loader",
inputs: [{ name: "text_encoder", type: "COMBO", widget: { name: "text_encoder" } }],
widgets_values: ["gemma_3_12B_it_fp4_mixed.safetensors"],
},
],
links: [
[597, -10, 0, 312, 0, "INT"],
[598, -10, 1, 299, 0, "INT"],
[606, -10, 2, 317, 0, "COMBO"],
],
},
],
},
},
});
expect(getNodeParamEntries(workflow.nodes[0])).toEqual([
["width", 1024],
["height", 768],
["text_encoder", "gemma_3_12B_it_fp4_mixed.safetensors"],
]);
});
it("falls back to node title when the type is an opaque hash", () => {
const node = {
id: 7,
type: "abcdefabcdefabcdefabcdefabcdefabcdef",
title: "Regional Prompt",
};
expect(getNodeDisplayName(node)).toBe("Regional Prompt");
expect(getNodeTypeLabel(node)).toBe("Subgraph");
});
it("expands runtime and serialized subgraphs with graph-qualified node ids", () => {
const workflow = {
nodes: [
{ id: 1, type: "ImageInput", pos: [0, 0], size: [140, 60] },
{
id: 2,
type: "sg-runtime",
pos: [220, 0],
size: [220, 140],
subgraph: {
id: "runtime-subgraph",
name: "Runtime Detailer",
nodes: [
{ id: 1, type: "InnerRuntime", pos: [0, 0], size: [120, 50] },
],
links: [],
},
},
{ id: 3, type: "sg-serialized", pos: [500, 0], size: [220, 140] },
],
links: [[1, 1, 0, 2, 0, "IMAGE"]],
rootGraph: {
subgraphs: [
{
id: "sg-serialized",
name: "Serialized Detailer",
nodes: [
{ id: 1, type: "InnerSerialized", pos: [0, 0], size: [120, 50] },
],
links: [[10, 1, 0, 1, 0, "IMAGE"]],
},
],
},
};
const expanded = expandSubgraphsForMinimap(workflow);
const ids = expanded.nodes.map((node) => String(node.id));
expect(ids).toContain("1");
expect(ids).toContain("2::1");
expect(ids).toContain("3::1");
expect(expanded.links).toEqual(
expect.arrayContaining([
expect.arrayContaining([1, 1, 0, 2, 0, "IMAGE"]),
expect.arrayContaining([10, "3::1", 0, "3::1", 0, "IMAGE"]),
]),
);
});
it("expands nested subgraphs that reference root-level definitions", () => {
const workflow = {
nodes: [
{ id: 10, type: "outer-sg", pos: [0, 0], size: [300, 220] },
],
links: [],
definitions: {
subgraphs: [
{
id: "outer-sg",
name: "Outer",
nodes: [
{ id: 20, type: "inner-sg", pos: [0, 0], size: [240, 160] },
],
links: [],
},
{
id: "inner-sg",
name: "Inner",
nodes: [
{ id: 30, type: "KSampler", pos: [10, 20], size: [160, 80] },
],
links: [],
},
],
},
};
const expanded = expandSubgraphsForMinimap(workflow);
const ids = expanded.nodes.map((node) => String(node.id));
expect(ids).toContain("10");
expect(ids).toContain("10::20");
expect(ids).toContain("10::20::30");
});
it("supports root-only mode for node listing and lookup", () => {
const workflow = {
nodes: [
{
id: 2,
type: "sg-runtime",
subgraph: {
id: "runtime-subgraph",
nodes: [{ id: 1, type: "InnerRuntime" }],
links: [],
},
},
],
links: [],
};
expect(getWorkflowNodes(workflow, { includeSubgraphs: false }).map((node: any) => String(node.id))).toEqual(["2"]);
expect(getWorkflowNodes(workflow, { includeSubgraphs: true }).map((node: any) => String(node.id))).toEqual(["2", "1"]);
expect(findWorkflowNode(workflow, "2::1", { includeSubgraphs: false })).toBeNull();
expect(findWorkflowNode(workflow, "2::1", { includeSubgraphs: true })?.type).toBe("InnerRuntime");
});
it("treats array-like widgets_values safely and prefers input slot labels", () => {
const node = {
id: 12,
type: "Sampler",
inputs: [
{ name: "seed", type: "INT", widget: { name: "seed" } },
{ name: "steps", type: "INT", widget: { name: "steps" } },
],
widgets: [{ name: "swapped_steps" }, { name: "swapped_seed" }],
widgets_values: {
0: 123,
1: 28,
length: 2,
},
};
expect(getNodeWidgetValueEntries(node)).toEqual([
{ label: "seed", value: 123, index: 0 },
{ label: "steps", value: 28, index: 1 },
]);
});
});