Files
Will Miao c712a079a9 fix(widgets): stop load-time widget rewrites from marking workflows dirty
TriggerWord Toggle serialized the transient `highlighted` UI state into
widgets_values, but highlight is recomputed after load from the current
selection/library, so an untouched workflow always compared dirty against
the saved JSON and ComfyUI prompted to save on close. Strip `highlighted`
from serialized tag values (backend never reads it).

Also restore saved widget values by widget name instead of positional
index (position drift corrupted unrelated widget slots on load), and
preserve `locked` in mergeLoras so load-time merging no longer drops it.

Workflows saved with `highlighted` keys will show as modified once after
upgrading; saving them once writes the clean format.

Fixes #893
2026-10-31 10:34:39 +08:00

198 lines
5.8 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
TAGS_WIDGET_MODULE,
SETTINGS_MODULE,
TRIGGER_WORD_TOGGLE_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
TAGS_WIDGET_MODULE: new URL("../../../web/comfyui/tags_widget.js", import.meta.url).pathname,
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
TRIGGER_WORD_TOGGLE_MODULE: new URL("../../../web/comfyui/trigger_word_toggle.js", import.meta.url).pathname,
}));
const extensionState = { current: null };
const registerExtensionMock = vi.fn((extension) => {
extensionState.current = extension;
});
vi.mock(APP_MODULE, () => ({
app: {
registerExtension: registerExtensionMock,
graph: {},
},
}));
vi.mock(API_MODULE, () => ({
api: {
addEventListener: vi.fn(),
},
}));
vi.mock(UTILS_MODULE, () => ({
CONVERTED_TYPE: "converted-widget",
getNodeFromGraph: vi.fn(),
getWidgetSerializedValue: (node, widgetName) => {
if (!node || !Array.isArray(node.widgets) || !Array.isArray(node.widgets_values)) {
return undefined;
}
const widgetIndex = node.widgets.findIndex((widget) => widget?.name === widgetName);
return widgetIndex >= 0 ? node.widgets_values[widgetIndex] : undefined;
},
}));
vi.mock(TAGS_WIDGET_MODULE, () => ({
addTagsWidget: (node, name) => {
const widget = { name, value: [] };
node.widgets.push(widget);
return { widget };
},
}));
vi.mock(SETTINGS_MODULE, () => ({
getWheelSensitivity: () => 0.02,
}));
function createNode({ widgetsValues } = {}) {
const node = {
comfyClass: "TriggerWord Toggle (LoraManager)",
widgets: [
{ name: "group_mode", value: true },
{ name: "default_active", value: true },
{ name: "allow_strength_adjustment", value: false },
],
widgets_values: widgetsValues,
addInput: vi.fn(),
addWidget: vi.fn((type, name, value) => {
const widget = { type, name, value };
node.widgets.push(widget);
return widget;
}),
};
return node;
}
describe("TriggerWord Toggle serialization", () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
beforeEach(async () => {
vi.resetModules();
extensionState.current = null;
registerExtensionMock.mockClear();
// Run nodeCreated's deferred setup synchronously.
globalThis.requestAnimationFrame = (callback) => {
callback();
return 0;
};
await import(TRIGGER_WORD_TOGGLE_MODULE);
});
afterEach(() => {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
it("restores saved widget values by widget name", async () => {
const savedTags = [
{ text: "foo", active: true, strength: null, highlighted: true },
];
const node = createNode({
widgetsValues: [true, true, false, savedTags, "foo, bar"],
});
await extensionState.current.nodeCreated(node);
expect(node.tagWidget).toBeDefined();
expect(node.tagWidget.value).toHaveLength(1);
expect(node.tagWidget.value[0]).toMatchObject({ text: "foo", active: true });
expect(node.originalMessageWidget.value).toBe("foo, bar");
});
it("strips transient highlighted state from serialized tag values", async () => {
const node = createNode({
widgetsValues: [true, true, false, [], ""],
});
await extensionState.current.nodeCreated(node);
node.tagWidget.value = [
{ text: "plain", active: true, highlighted: false, strength: null },
{
text: "group a, group b",
active: true,
highlighted: true,
strength: null,
items: [
{ text: "group a", active: true, highlighted: true, strength: null },
{ text: "group b", active: false, highlighted: false, strength: null },
],
},
];
const serialized = node.tagWidget.serializeValue();
expect(serialized).toEqual([
{ text: "plain", active: true, strength: null },
{
text: "group a, group b",
active: true,
strength: null,
items: [
{ text: "group a", active: true, strength: null },
{ text: "group b", active: false, strength: null },
],
},
]);
});
it("keeps strength text formatting while stripping highlighted state", async () => {
const node = createNode({
widgetsValues: [true, true, false, [], ""],
});
await extensionState.current.nodeCreated(node);
node.tagWidget.value = [
{ text: "styled", active: true, highlighted: true, strength: 0.5 },
];
const serialized = node.tagWidget.serializeValue();
expect(serialized).toEqual([
{ text: "(styled:0.50)", active: true, strength: 0.5 },
]);
});
it("serializes restored values identically to the loaded save data", async () => {
// Simulates a workflow saved by the current version (no highlighted keys):
// load -> restore -> highlight recompute must not change serialized output.
const savedTags = [
{
text: "masterpiece, best quality",
active: true,
strength: null,
items: [
{ text: "masterpiece", active: true, strength: null },
{ text: "best quality", active: true, strength: null },
],
},
];
const node = createNode({
widgetsValues: [true, true, false, savedTags, "masterpiece, best quality"],
});
await extensionState.current.nodeCreated(node);
// Highlight recompute ran during nodeCreated (empty highlight set).
const serialized = node.tagWidget.serializeValue();
expect(serialized).toEqual(savedTags);
});
});