mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-21 13:01:27 -03:00
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { APP_MODULE, API_MODULE, STYLES_MODULE, REGISTRY_MODULE, appMock, apiMock, registeredExtensions } =
|
|
vi.hoisted(() => {
|
|
const registeredExtensions = [];
|
|
const appMock = {
|
|
graph: null,
|
|
registerExtension: (ext) => registeredExtensions.push(ext),
|
|
};
|
|
const apiMock = {
|
|
clientId: "client-1",
|
|
initialClientId: null,
|
|
addEventListener: vi.fn(),
|
|
};
|
|
return {
|
|
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
|
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
|
|
STYLES_MODULE: new URL("../../../web/comfyui/lm_styles_loader.js", import.meta.url).pathname,
|
|
REGISTRY_MODULE: new URL("../../../web/comfyui/workflow_registry.js", import.meta.url).pathname,
|
|
appMock,
|
|
apiMock,
|
|
registeredExtensions,
|
|
};
|
|
});
|
|
|
|
vi.mock(APP_MODULE, () => ({ app: appMock }));
|
|
vi.mock(API_MODULE, () => ({ api: apiMock }));
|
|
vi.mock(STYLES_MODULE, () => ({ ensureLmStyles: vi.fn() }));
|
|
|
|
function createTextEncodeNode({ linked = false, id = 1 } = {}) {
|
|
const textWidget = { name: "text", type: "customtext", value: "old prompt", callback: null };
|
|
return {
|
|
id,
|
|
comfyClass: "CLIPTextEncode",
|
|
title: "CLIP Text Encode",
|
|
mode: 0,
|
|
properties: {},
|
|
widgets: [textWidget, { name: "clip", type: "combo" }],
|
|
widgets_values: ["old prompt", "clip-1"],
|
|
inputs: [
|
|
{ name: "text", type: "STRING", widget: textWidget, link: linked ? 101 : null },
|
|
{ name: "clip", type: "CLIP", link: null },
|
|
],
|
|
setDirtyCanvas: vi.fn(),
|
|
graph: null,
|
|
};
|
|
}
|
|
|
|
function createSubgraph({ id = "sub-1", nodes = [] } = {}) {
|
|
const graph = {
|
|
id,
|
|
_nodes: nodes,
|
|
_subgraphs: new Map(),
|
|
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
|
|
events: { addEventListener: vi.fn() },
|
|
};
|
|
for (const node of nodes) {
|
|
node.graph = graph;
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
function createGraph({ nodes = [], subgraphs = [] } = {}) {
|
|
const graph = {
|
|
id: "root",
|
|
_nodes: nodes,
|
|
_subgraphs: new Map(),
|
|
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
|
|
events: { addEventListener: vi.fn() },
|
|
};
|
|
for (const subgraph of subgraphs) {
|
|
graph._subgraphs.set(subgraph.id, subgraph);
|
|
}
|
|
for (const node of nodes) {
|
|
node.graph = graph;
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
function lastRegisterPayload(fetchMock) {
|
|
const calls = fetchMock.mock.calls.filter(
|
|
([url]) => url === "/api/lm/register-nodes"
|
|
);
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
return JSON.parse(calls[calls.length - 1][1].body);
|
|
}
|
|
|
|
describe("LoraManager.WorkflowRegistry", () => {
|
|
let extension;
|
|
let fetchMock;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
registeredExtensions.length = 0;
|
|
appMock.graph = null;
|
|
apiMock.addEventListener.mockClear();
|
|
fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
global.fetch = fetchMock;
|
|
await import(REGISTRY_MODULE);
|
|
extension = registeredExtensions.find(
|
|
(ext) => ext.name === "LoraManager.WorkflowRegistry"
|
|
);
|
|
expect(extension).toBeDefined();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.fetch;
|
|
});
|
|
|
|
describe("refreshRegistry", () => {
|
|
it("registers an unconnected CLIPTextEncode as a text target", async () => {
|
|
appMock.graph = createGraph({ nodes: [createTextEncodeNode()] });
|
|
|
|
await extension.refreshRegistry(true);
|
|
|
|
const body = lastRegisterPayload(fetchMock);
|
|
expect(body.nodes).toHaveLength(1);
|
|
expect(body.nodes[0].capabilities.has_text_widget).toBe(true);
|
|
expect(body.nodes[0].capabilities.text_widget_connected).toBe(false);
|
|
});
|
|
|
|
it("excludes a CLIPTextEncode whose text input is connected", async () => {
|
|
appMock.graph = createGraph({ nodes: [createTextEncodeNode({ linked: true })] });
|
|
|
|
await extension.refreshRegistry(true);
|
|
|
|
const body = lastRegisterPayload(fetchMock);
|
|
expect(body.nodes).toHaveLength(1);
|
|
expect(body.nodes[0].capabilities.has_text_widget).toBe(false);
|
|
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
|
|
});
|
|
|
|
it("registers connection state for nodes inside subgraphs", async () => {
|
|
const inner = createTextEncodeNode({ linked: true, id: 7 });
|
|
const subgraph = createSubgraph({ id: "sub-1", nodes: [inner] });
|
|
appMock.graph = createGraph({ subgraphs: [subgraph] });
|
|
|
|
await extension.refreshRegistry(true);
|
|
|
|
const body = lastRegisterPayload(fetchMock);
|
|
expect(body.nodes).toHaveLength(1);
|
|
expect(body.nodes[0].graph_id).toBe("sub-1");
|
|
expect(body.nodes[0].node_id).toBe(7);
|
|
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
|
|
});
|
|
|
|
it("re-registers when text_widget_connected changes (fingerprint)", async () => {
|
|
const node = createTextEncodeNode();
|
|
appMock.graph = createGraph({ nodes: [node] });
|
|
|
|
await extension.refreshRegistry(true);
|
|
await extension.refreshRegistry();
|
|
expect(
|
|
fetchMock.mock.calls.filter(([url]) => url === "/api/lm/register-nodes")
|
|
).toHaveLength(1);
|
|
|
|
node.inputs[0].link = 101;
|
|
await extension.refreshRegistry();
|
|
const body = lastRegisterPayload(fetchMock);
|
|
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("applyWidgetUpdate (inject_text)", () => {
|
|
it("updates the widget value when the text input is not connected", async () => {
|
|
const node = createTextEncodeNode();
|
|
const callback = vi.fn();
|
|
node.widgets[0].callback = callback;
|
|
appMock.graph = createGraph({ nodes: [node] });
|
|
extension.flashWidget = vi.fn();
|
|
|
|
await extension.applyWidgetUpdate({
|
|
node_id: 1,
|
|
action: "inject_text",
|
|
value: "hello",
|
|
mode: "replace",
|
|
});
|
|
|
|
expect(node.widgets[0].value).toBe("hello");
|
|
expect(node.widgets_values[0]).toBe("hello");
|
|
expect(callback).toHaveBeenCalledWith("hello");
|
|
});
|
|
|
|
it("skips inject_text when the target widget is connected and self-heals the registry", async () => {
|
|
const node = createTextEncodeNode({ linked: true });
|
|
appMock.graph = createGraph({ nodes: [node] });
|
|
extension.flashWidget = vi.fn();
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
await extension.applyWidgetUpdate({
|
|
node_id: 1,
|
|
graph_id: "root",
|
|
action: "inject_text",
|
|
value: "new prompt",
|
|
mode: "replace",
|
|
});
|
|
|
|
expect(node.widgets[0].value).toBe("old prompt");
|
|
expect(node.widgets_values[0]).toBe("old prompt");
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("connected to an input"),
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
fetchMock.mock.calls.some(([url]) => url === "/api/lm/register-nodes")
|
|
).toBe(true);
|
|
});
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("loadWorkflowFromMessage", () => {
|
|
beforeEach(() => {
|
|
appMock.loadApiJson = vi.fn().mockResolvedValue(undefined);
|
|
appMock.loadGraphData = vi.fn().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("warns and returns when the message carries no workflow payload", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
await extension.loadWorkflowFromMessage({ name: "My Recipe" });
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("without a workflow payload"),
|
|
expect.anything()
|
|
);
|
|
expect(appMock.loadApiJson).not.toHaveBeenCalled();
|
|
expect(appMock.loadGraphData).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("parses a string workflow before loading", async () => {
|
|
const workflow = { nodes: [], links: [] };
|
|
|
|
await extension.loadWorkflowFromMessage({
|
|
workflow: JSON.stringify(workflow),
|
|
name: "Parsed",
|
|
});
|
|
|
|
expect(appMock.loadGraphData).toHaveBeenCalledWith(
|
|
workflow,
|
|
true,
|
|
true,
|
|
"Parsed",
|
|
{ openSource: "file_button" }
|
|
);
|
|
});
|
|
|
|
it("warns and returns when the workflow string is not valid JSON", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
await extension.loadWorkflowFromMessage({ workflow: "{not json" });
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("non-JSON workflow string"),
|
|
expect.anything()
|
|
);
|
|
expect(appMock.loadApiJson).not.toHaveBeenCalled();
|
|
expect(appMock.loadGraphData).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("loads API-format workflows via app.loadApiJson", async () => {
|
|
const workflow = {
|
|
"1": { class_type: "KSampler", inputs: {} },
|
|
"2": { class_type: "CLIPTextEncode", inputs: {} },
|
|
};
|
|
|
|
await extension.loadWorkflowFromMessage({ workflow, name: "API Recipe" });
|
|
|
|
expect(appMock.loadApiJson).toHaveBeenCalledWith(workflow, "API Recipe");
|
|
expect(appMock.loadGraphData).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads UI-format workflows via app.loadGraphData", async () => {
|
|
const workflow = { nodes: [{ id: 1 }], links: [] };
|
|
|
|
await extension.loadWorkflowFromMessage({ workflow, name: "UI Recipe" });
|
|
|
|
expect(appMock.loadGraphData).toHaveBeenCalledWith(
|
|
workflow,
|
|
true,
|
|
true,
|
|
"UI Recipe",
|
|
{ openSource: "file_button" }
|
|
);
|
|
expect(appMock.loadApiJson).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("defaults the workflow name to 'Recipe Workflow'", async () => {
|
|
const workflow = { nodes: [], links: [] };
|
|
|
|
await extension.loadWorkflowFromMessage({ workflow });
|
|
|
|
expect(appMock.loadGraphData).toHaveBeenCalledWith(
|
|
workflow,
|
|
true,
|
|
true,
|
|
"Recipe Workflow",
|
|
{ openSource: "file_button" }
|
|
);
|
|
});
|
|
|
|
it("logs an error when loading the workflow throws", async () => {
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const failure = new Error("load failed");
|
|
appMock.loadGraphData.mockRejectedValue(failure);
|
|
|
|
await extension.loadWorkflowFromMessage({
|
|
workflow: { nodes: [], links: [] },
|
|
});
|
|
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("failed to load workflow"),
|
|
failure
|
|
);
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("setup link-change hooks", () => {
|
|
it("hooks root events, existing subgraphs, and future subgraphs", () => {
|
|
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
|
|
const graph = createGraph({ subgraphs: [subgraph] });
|
|
appMock.graph = graph;
|
|
|
|
extension.setup();
|
|
|
|
expect(graph.events.addEventListener).toHaveBeenCalledWith(
|
|
"node:slot-links:changed",
|
|
expect.any(Function)
|
|
);
|
|
expect(graph.events.addEventListener).toHaveBeenCalledWith(
|
|
"subgraph-created",
|
|
expect.any(Function)
|
|
);
|
|
expect(subgraph.events.addEventListener).toHaveBeenCalledWith(
|
|
"node:slot-links:changed",
|
|
expect.any(Function)
|
|
);
|
|
|
|
const createdHandler = graph.events.addEventListener.mock.calls.find(
|
|
([name]) => name === "subgraph-created"
|
|
)[1];
|
|
const laterSubgraph = createSubgraph({ id: "sub-2", nodes: [] });
|
|
createdHandler({ subgraph: laterSubgraph });
|
|
expect(laterSubgraph.events.addEventListener).toHaveBeenCalledWith(
|
|
"node:slot-links:changed",
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
});
|
|
});
|