587 lines
22 KiB
TypeScript
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 },
|
|
]);
|
|
});
|
|
});
|