Files
willmiao-ComfyUI-Lora-Manager/tests/frontend/utils/triggerWordSync.test.js
T
Will Miao fe33518221 fix(widgets): coalesce trigger-word syncs while the graph loads
ComfyUI creates every node before configuring any of them, so while a Lora
Loader restores its `loras` widget the upstream cycler/randomizer already
exists but still holds its factory-default config. The resulting sync
resolved an empty LoRA set, cleared the connected TriggerWord Toggle, and the
upstream node's own configure repopulated it a moment later. A draft or
change-tracker snapshot taken in that window keeps the cleared state as the
baseline, so the repopulated - and correct - graph compares dirty.

Coalesce syncs per source node and send only the last computed set. The
pending timer is stored on the node because utils.js is loaded twice (directly
by lora_loader.js and inlined into the Vue widget bundle), so both call sites
have to share it.

Reproduced on Lora_Cycler: the tracker baseline held
`toggle_trigger_words: []` / `orinalMessage: ""` while the live graph matched
the saved file byte for byte.
2026-11-01 08:13:19 +08:00

169 lines
4.9 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, UTILS_MODULE } = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
}));
vi.mock(APP_MODULE, () => ({
app: {
graph: null,
registerExtension: vi.fn(),
ui: {
settings: {
getSettingValue: vi.fn(),
},
},
},
}));
const TWT_CLASS = "TriggerWord Toggle (LoraManager)";
const LOADER_CLASS = "Lora Loader (LoraManager)";
const CYCLER_CLASS = "Lora Cycler (LoraManager)";
function createGraph(nodes, links) {
const graph = {
_nodes: nodes,
links,
getNodeById(id) {
return nodes.find((node) => node.id === id) ?? null;
},
};
nodes.forEach((node) => {
node.graph = graph;
});
return graph;
}
/**
* Loader(56) -> TriggerWord Toggle(55) via link 123, with a LORA_STACK input
* from Cycler(83) via link 151 - the shape of the Lora_Cycler workflow.
*/
function createCyclerGraph() {
const twt = {
id: 55,
comfyClass: TWT_CLASS,
widgets: [],
inputs: [],
outputs: [{ name: "filtered_trigger_words", links: [135] }],
};
const loader = {
id: 56,
comfyClass: LOADER_CLASS,
widgets: [{ name: "loras", value: [] }],
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 151 }],
outputs: [{ name: "trigger_words", links: [123] }],
};
// Before its own configure() runs, the cycler widget still holds the
// factory default (no current LoRA).
const cycler = {
id: 83,
comfyClass: CYCLER_CLASS,
widgets: [
{
name: "cycler_config",
value: { current_lora_filename: "", current_lora_name: "" },
},
],
inputs: [],
outputs: [{ name: "LORA_STACK", links: [151] }],
};
const graph = createGraph(
[twt, loader, cycler],
new Map([
[123, { id: 123, origin_id: 56, origin_slot: 2, target_id: 55, target_slot: 3 }],
[151, { id: 151, origin_id: 83, origin_slot: 0, target_id: 56, target_slot: 4 }],
])
);
return { graph, twt, loader, cycler };
}
describe("updateConnectedTriggerWords", () => {
let collectActiveLorasFromChain;
let updateConnectedTriggerWords;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers();
({ collectActiveLorasFromChain, updateConnectedTriggerWords } = await import(
UTILS_MODULE
));
fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("sends only the final LoRA set when the upstream node configures later", async () => {
const { loader, cycler } = createCyclerGraph();
// Loader restores its own (empty) `loras` widget while the cycler still
// holds the widget default: the chain looks empty.
expect(Array.from(collectActiveLorasFromChain(loader))).toEqual([]);
updateConnectedTriggerWords(loader, collectActiveLorasFromChain(loader));
// Same task, moments later: the cycler restores its saved config.
cycler.widgets[0].value = {
current_lora_filename: "Illustrious/realistic/114558v4df2fsdf5",
current_lora_name: "Illustrious/realistic/114558v4df2fsdf5",
};
updateConnectedTriggerWords(loader, collectActiveLorasFromChain(loader));
await vi.advanceTimersByTimeAsync(60);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/lm/loras/get_trigger_words");
expect(JSON.parse(init.body)).toEqual({
lora_names: ["Illustrious/realistic/114558v4df2fsdf5"],
node_ids: [{ node_id: 55, graph_id: "root" }],
});
});
it("keeps the pending timer on the node so both bundle copies coalesce", async () => {
const { loader } = createCyclerGraph();
updateConnectedTriggerWords(loader, new Set(["A"]));
// Both copies share the node object; the guard lives on it.
expect(loader.__lmTriggerWordSyncTimer).toBeTruthy();
await vi.advanceTimersByTimeAsync(60);
expect(loader.__lmTriggerWordSyncTimer).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("still clears the toggle nodes when the LoRAs are really gone", async () => {
const { loader } = createCyclerGraph();
updateConnectedTriggerWords(loader, new Set());
await vi.advanceTimersByTimeAsync(60);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).lora_names).toEqual([]);
});
it("does nothing when no toggle node is connected", async () => {
const { loader, twt } = createCyclerGraph();
twt.outputs[0].links = [];
loader.outputs[0].links = [];
updateConnectedTriggerWords(loader, new Set(["A"]));
await vi.advanceTimersByTimeAsync(60);
expect(fetchMock).not.toHaveBeenCalled();
});
});