Files
willmiao-ComfyUI-Lora-Manager/tests/frontend/core/workflowWriteback.test.js
T
Will Miao 92de2dbc52 feat(workflows): add Create/Update to the tab context menu
ComfyUI exposes commands and menuCommands for the menu bar, but the
workflow tab context menu is built inside WorkflowTab.vue and has no
extension hook. Reuse the registered create/update actions and inject
the entries into the reka-ui portal, the same fallback the Graph menu
already uses.

The entries sit above the tab-close group. Clicking one activates the
right-clicked tab through app.extensionManager.workflow.openWorkflow
(the switch the native Save/Export entries use), so the action targets
that tab's graph instead of whichever tab was active. Update is enabled
from that tab's own lm_source marker and link-status check, and a menu
instance is only enhanced once.
2026-10-30 18:21:16 +08:00

671 lines
24 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
WRITEBACK_MODULE,
appMock,
apiMock,
toastMock,
registeredExtensions,
} = vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
rootGraph: null,
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
listeners: {},
queuePrompt: async () => ({ prompt_id: "p-1" }),
addEventListener: (event, handler) => {
(apiMock.listeners[event] ??= []).push(handler);
},
};
const toastMock = 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,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
WRITEBACK_MODULE: new URL("../../../web/comfyui/workflow_writeback.js", import.meta.url).pathname,
appMock,
apiMock,
toastMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(UTILS_MODULE, () => ({ showToast: toastMock }));
function createRootGraph({ marker = null, serialized = null } = {}) {
const extra = marker ? { lm_source: marker, ds: { scale: 1 } } : { ds: { scale: 1 } };
return {
extra,
serialize: vi.fn(() => serialized ?? { nodes: [], links: [], extra: JSON.parse(JSON.stringify(extra)) }),
};
}
function fetchCalls(fetchMock, url) {
return fetchMock.mock.calls.filter(([callUrl]) => callUrl === url);
}
function fetchCallsStartingWith(fetchMock, prefix) {
return fetchMock.mock.calls.filter(
([callUrl]) => typeof callUrl === "string" && callUrl.startsWith(prefix)
);
}
function okResponse(body = { success: true, sha256: "new-hash" }) {
return { ok: true, status: 200, json: async () => body };
}
describe("LoraManager.WorkflowWriteback", () => {
let extension;
let moduleExports;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
apiMock.listeners = {};
delete apiMock.__lmQueuePromptHooked;
apiMock.queuePrompt = async () => ({ prompt_id: "p-1" });
document.body.innerHTML = "";
appMock.rootGraph = null;
appMock.graph = null;
appMock.extensionManager = {
workflow: { activeWorkflow: { filename: "My Flow" } },
};
toastMock.mockClear();
fetchMock = vi.fn().mockResolvedValue(okResponse());
global.fetch = fetchMock;
moduleExports = await import(WRITEBACK_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowWriteback"
);
expect(extension).toBeDefined();
});
it("registers the create and write-back commands in the File menu group", () => {
expect(extension.commands.map((command) => command.id)).toEqual([
"LoraManager.CreateManagedWorkflow",
"LoraManager.UpdateManagedWorkflow",
]);
expect(extension.menuCommands).toEqual([
{
path: ["File"],
commands: [
"LoraManager.CreateManagedWorkflow",
"LoraManager.UpdateManagedWorkflow",
],
},
]);
});
describe("workflow actions menu injection", () => {
function mountMenu() {
document.body.innerHTML = `
<div data-testid="view-mode-toggle"></div>
<div role="menu">
<i class="pi pi-pencil"></i>
<i class="pi pi-save"></i>
<i class="pi pi-download"></i>
</div>
`;
}
it("appends both items without throwing on multi-class state", async () => {
mountMenu();
extension.setup();
document.querySelector('[data-testid="view-mode-toggle"]').click();
await vi.waitFor(() => {
expect(
document.querySelector('[data-lm-action="create-workflow"]')
).not.toBeNull();
});
const createItem = document.querySelector(
'[data-lm-action="create-workflow"]'
);
const updateItem = document.querySelector(
'[data-lm-action="update-workflow"]'
);
expect(createItem.textContent).toContain("Create LM-managed workflow");
expect(updateItem.textContent).toContain("Update LM-managed workflow");
// Not linked: create is enabled (optimistic), update is disabled.
expect(createItem.className).toContain("cursor-pointer");
expect(updateItem.className).toContain("opacity-50");
// Both entries keep the shared menu-item layout (padding/alignment);
// missing base classes make the item look shifted left.
for (const item of [createItem, updateItem]) {
expect(item.className).toContain("flex");
expect(item.className).toContain("min-h-6");
expect(item.className).toContain("gap-2");
expect(item.className).toContain("p-2");
}
});
});
describe("workflow tab context menu injection", () => {
function mountTabs({ marker = null } = {}) {
document.body.innerHTML = `
<div class="workflow-tab" data-state="closed" aria-controls="tab-menu-other">
<span class="workflow-label">Other Flow</span>
</div>
<div class="workflow-tab" data-state="closed" aria-controls="tab-menu-target">
<span class="workflow-label">My Flow</span>
</div>
<div id="tab-menu-target" role="menu">
<div role="menuitem"><i class="pi pi-pencil"></i><span>Rename</span></div>
<div role="separator"></div>
<div role="menuitem"><i class="pi pi-save"></i><span>Save</span></div>
<div role="separator"></div>
<div role="menuitem"><i class="pi pi-times"></i><span>Close Tab</span></div>
<div role="menuitem">
<span><i class="pi pi-times"></i><i class="pi pi-arrows-h"></i></span>
<span>Close Other Tabs</span>
</div>
</div>
`;
const activeWorkflow = { filename: "Other Flow" };
const targetWorkflow = {
filename: "My Flow",
activeState: marker ? { extra: { lm_source: marker } } : {},
};
appMock.extensionManager.workflow = {
activeWorkflow,
openWorkflows: [activeWorkflow, targetWorkflow],
openWorkflow: vi.fn(),
};
return { activeWorkflow, targetWorkflow };
}
function openTabMenu() {
const tabs = document.querySelectorAll(".workflow-tab");
tabs[1].dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true })
);
}
it("adds create/update before the close group using the tab's own marker", async () => {
const marker = { file_path: "/lib/target.json", sha256: "abc" };
mountTabs({ marker });
appMock.rootGraph = createRootGraph();
extension.setup();
openTabMenu();
await vi.waitFor(() => {
expect(
document.querySelector('[data-lm-tab-action="create-workflow"]')
).not.toBeNull();
});
const menu = document.getElementById("tab-menu-target");
const createItem = menu.querySelector(
'[data-lm-tab-action="create-workflow"]'
);
const updateItem = menu.querySelector(
'[data-lm-tab-action="update-workflow"]'
);
expect(createItem.textContent).toContain("Create LM-managed workflow");
expect(updateItem.textContent).toContain("Update LM-managed workflow");
// Active graph has no marker, but the right-clicked tab does.
expect(updateItem.className).toContain("cursor-pointer");
// Both keep the native menu-item layout and sit before Close Tab.
for (const item of [createItem, updateItem]) {
expect(item.className).toContain("flex");
expect(item.className).toContain("p-2");
}
const closeTab = Array.from(menu.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent.includes("Close Tab")
);
expect(
createItem.compareDocumentPosition(closeTab) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
// Re-opening the same menu instance must not duplicate the entries.
openTabMenu();
await new Promise((resolve) => setTimeout(resolve, 150));
expect(
menu.querySelectorAll('[data-lm-tab-action="create-workflow"]').length
).toBe(1);
});
it("activates the right-clicked tab before updating it", async () => {
const marker = { file_path: "/lib/target.json", sha256: "abc" };
const { targetWorkflow } = mountTabs({ marker });
appMock.rootGraph = createRootGraph();
const openWorkflow = appMock.extensionManager.workflow.openWorkflow;
openWorkflow.mockImplementation(async (workflow) => {
appMock.extensionManager.workflow.activeWorkflow = workflow;
appMock.rootGraph = createRootGraph({ marker });
return workflow;
});
fetchMock.mockImplementation(async (url) => {
if (
typeof url === "string" &&
url.startsWith("/api/lm/workflows/link-status")
) {
return okResponse({ success: true, exists: true });
}
return okResponse();
});
extension.setup();
openTabMenu();
await vi.waitFor(() => {
expect(
document.querySelector('[data-lm-tab-action="update-workflow"]')
).not.toBeNull();
});
document
.querySelector('[data-lm-tab-action="update-workflow"]')
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => {
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1);
});
expect(openWorkflow).toHaveBeenCalledWith(targetWorkflow);
const body = JSON.parse(
fetchCalls(fetchMock, "/api/lm/workflows/content")[0][1].body
);
expect(body.file_path).toBe(marker.file_path);
});
});
describe("getActiveLmSource", () => {
it("returns null when the graph carries no marker", () => {
appMock.rootGraph = createRootGraph();
expect(moduleExports.getActiveLmSource()).toBeNull();
});
it("returns the marker when present", () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
expect(moduleExports.getActiveLmSource()).toBe(marker);
});
it("returns null when the marker has no usable file_path", () => {
appMock.rootGraph = createRootGraph({ marker: { sha256: "abc" } });
expect(moduleExports.getActiveLmSource()).toBeNull();
});
});
describe("link state reporting", () => {
it("reports the marker state after a graph load (afterConfigureGraph)", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
await extension.afterConfigureGraph();
const reports = fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state");
expect(reports).toHaveLength(1);
const body = JSON.parse(reports[0][1].body);
expect(body).toEqual({
client_id: "client-1",
state: { file_path: "/lib/flow.json", sha256: "abc", dirty: false },
});
});
it("reports a null state when the active graph is not LM-linked", async () => {
appMock.rootGraph = createRootGraph();
await extension.afterConfigureGraph();
const reports = fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state");
const body = JSON.parse(reports[0][1].body);
expect(body.state).toBeNull();
});
it("drops a marker whose library entry is gone and reports null", async () => {
const marker = { file_path: "/lib/deleted.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
fetchMock.mockImplementation(async (url) =>
url.startsWith("/api/lm/workflows/link-status")
? okResponse({ success: true, exists: false })
: okResponse()
);
await extension.afterConfigureGraph();
expect(appMock.rootGraph.extra.lm_source).toBeUndefined();
const reports = fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state");
expect(reports.length).toBeGreaterThan(0);
expect(JSON.parse(reports.at(-1)[1].body).state).toBeNull();
});
it("marks the graph dirty and reports on the first graphChanged", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
extension.setup();
const handler = apiMock.listeners["graphChanged"][0];
handler({});
expect(marker.dirty).toBe(true);
await vi.waitFor(() =>
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1)
);
const body = JSON.parse(
fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")[0][1].body
);
expect(body.state.dirty).toBe(true);
// A second change does not re-report (already dirty).
handler({});
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1);
});
it("ignores graphChanged when the active graph is not LM-linked", () => {
appMock.rootGraph = createRootGraph();
extension.setup();
apiMock.listeners["graphChanged"][0]({});
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(0);
});
});
describe("updateManagedWorkflow", () => {
it("warns and does not call the backend without a marker", async () => {
appMock.rootGraph = createRootGraph();
await moduleExports.updateManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "warn" })
);
});
it("PUTs the serialized graph without the marker and refreshes the hash", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await moduleExports.updateManagedWorkflow();
const puts = fetchCalls(fetchMock, "/api/lm/workflows/content");
expect(puts).toHaveLength(1);
const [url, options] = puts[0];
expect(url).toBe("/api/lm/workflows/content");
expect(options.method).toBe("PUT");
const body = JSON.parse(options.body);
expect(body.file_path).toBe("/lib/flow.json");
expect(body.base_sha256).toBe("old-hash");
expect(body.force).toBe(false);
expect(body.workflow.extra.lm_source).toBeUndefined();
// Other extra keys survive the strip.
expect(body.workflow.extra.ds).toEqual({ scale: 1 });
expect(marker.sha256).toBe("new-hash");
// Write-back clears the dirty flag and re-reports the link state.
expect(marker.dirty).toBe(false);
await vi.waitFor(() =>
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1)
);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "success" })
);
});
it("retries with force after a conflict only when the user confirms", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
const confirmMock = vi.fn().mockReturnValue(false);
vi.stubGlobal("confirm", confirmMock);
fetchMock.mockImplementation(async (url) =>
url === "/api/lm/workflows/content"
? { status: 409, json: async () => ({ success: false, error: "conflict" }) }
: okResponse()
);
await moduleExports.updateManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1);
expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: "success" })
);
confirmMock.mockReturnValue(true);
let conflictServed = false;
fetchMock.mockImplementation(async (url) => {
if (url !== "/api/lm/workflows/content") return okResponse();
if (!conflictServed) {
conflictServed = true;
return { status: 409, json: async () => ({ success: false, error: "conflict" }) };
}
return okResponse({ success: true, sha256: "forced-hash" });
});
await moduleExports.updateManagedWorkflow();
const puts = fetchCalls(fetchMock, "/api/lm/workflows/content");
expect(puts).toHaveLength(3);
const forcedBody = JSON.parse(puts[2][1].body);
expect(forcedBody.force).toBe(true);
expect(marker.sha256).toBe("forced-hash");
vi.unstubAllGlobals();
});
it("reports backend errors via toast", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
fetchMock.mockResolvedValue({
status: 400,
json: async () => ({ success: false, error: "workflow is not a ComfyUI graph document" }),
});
await moduleExports.updateManagedWorkflow();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "error" })
);
});
it("clears the marker when the library copy is gone", async () => {
const marker = { file_path: "/lib/deleted.json", sha256: "abc", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
fetchMock.mockResolvedValue({
status: 404,
json: async () => ({ success: false, error: "not_found" }),
});
await moduleExports.updateManagedWorkflow();
expect(appMock.rootGraph.extra.lm_source).toBeUndefined();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "warn" })
);
});
});
describe("createManagedWorkflow", () => {
it("warns and does not call the backend without an open graph", async () => {
await moduleExports.createManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/create")).toHaveLength(0);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "warn" })
);
});
it("POSTs the graph without the marker and stamps the new link", async () => {
const marker = { file_path: "/lib/old.json", sha256: "old-hash" };
appMock.rootGraph = createRootGraph({ marker });
vi.stubGlobal("confirm", vi.fn().mockReturnValue(true));
fetchMock.mockImplementation(async (url) => {
if (url === "/api/lm/workflows/create") {
return okResponse({
success: true,
file_path: "/lib/new.json",
sha256: "new-hash",
name: "My Flow",
preview: false,
});
}
return okResponse();
});
await moduleExports.createManagedWorkflow();
const posts = fetchCalls(fetchMock, "/api/lm/workflows/create");
expect(posts).toHaveLength(1);
const body = JSON.parse(posts[0][1].body);
expect(body.name).toBe("My Flow");
expect(body.workflow.extra.lm_source).toBeUndefined();
expect(body.workflow.extra.ds).toEqual({ scale: 1 });
// The created entry is linked immediately: marker replaced, state reported.
expect(appMock.rootGraph.extra.lm_source).toEqual({
file_path: "/lib/new.json",
sha256: "new-hash",
});
expect(
fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")
).toHaveLength(1);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "success" })
);
});
it("replaces a stale marker without prompting", async () => {
appMock.rootGraph = createRootGraph({
marker: { file_path: "/lib/deleted.json", sha256: "old" },
});
const confirmMock = vi.fn().mockReturnValue(true);
vi.stubGlobal("confirm", confirmMock);
fetchMock.mockImplementation(async (url) => {
if (url.startsWith("/api/lm/workflows/link-status")) {
return okResponse({ success: true, exists: false });
}
if (url === "/api/lm/workflows/create") {
return okResponse({
success: true,
file_path: "/lib/new.json",
sha256: "new-hash",
name: "My Flow",
});
}
return okResponse();
});
await moduleExports.createManagedWorkflow();
expect(confirmMock).not.toHaveBeenCalled();
expect(fetchCalls(fetchMock, "/api/lm/workflows/create")).toHaveLength(1);
expect(appMock.rootGraph.extra.lm_source).toEqual({
file_path: "/lib/new.json",
sha256: "new-hash",
});
vi.unstubAllGlobals();
});
it("sends the prompt id of the last run queued from this tab", async () => {
apiMock.queuePrompt = vi.fn(async () => ({ prompt_id: "p-preview" }));
extension.setup();
await apiMock.queuePrompt(0, {});
apiMock.listeners["execution_success"][0]({ detail: { prompt_id: "p-preview" } });
appMock.rootGraph = createRootGraph();
await moduleExports.createManagedWorkflow();
const posts = fetchCalls(fetchMock, "/api/lm/workflows/create");
expect(posts).toHaveLength(1);
expect(JSON.parse(posts[0][1].body).preview_prompt_id).toBe("p-preview");
});
it("confirms before copying an already linked graph", async () => {
const marker = { file_path: "/lib/old.json", sha256: "old-hash" };
appMock.rootGraph = createRootGraph({ marker });
const confirmMock = vi.fn().mockReturnValue(false);
vi.stubGlobal("confirm", confirmMock);
await moduleExports.createManagedWorkflow();
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(fetchCalls(fetchMock, "/api/lm/workflows/create")).toHaveLength(0);
confirmMock.mockReturnValue(true);
await moduleExports.createManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/create")).toHaveLength(1);
vi.unstubAllGlobals();
});
it("reports backend errors via toast", async () => {
appMock.rootGraph = createRootGraph();
fetchMock.mockResolvedValue({
status: 400,
json: async () => ({
success: false,
error: "The workflow library folder is not configured in LoRA Manager settings",
}),
});
await moduleExports.createManagedWorkflow();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
severity: "error",
detail: expect.stringContaining("workflow library folder"),
})
);
});
});
describe("lm_collect_workflow handling", () => {
function fireCollect(detail) {
extension.setup();
const handler = apiMock.listeners["lm_collect_workflow"][0];
return handler({ detail });
}
it("ignores requests for workflows this tab does not hold", async () => {
const marker = { file_path: "/lib/other.json", sha256: "abc", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r1", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
expect(fetchCalls(fetchMock, "/api/lm/workflows/collect-result")).toHaveLength(0);
});
it("answers no_changes without writing back when the graph is clean", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc", dirty: false };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r2", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
const results = fetchCalls(fetchMock, "/api/lm/workflows/collect-result");
expect(results).toHaveLength(1);
expect(JSON.parse(results[0][1].body)).toEqual({
request_id: "r2",
success: false,
error: "no_changes",
});
});
it("writes back and posts the outcome when the graph is dirty", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r3", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1);
const results = fetchCalls(fetchMock, "/api/lm/workflows/collect-result");
expect(results).toHaveLength(1);
expect(JSON.parse(results[0][1].body)).toEqual({
request_id: "r3",
success: true,
sha256: "new-hash",
});
expect(marker.dirty).toBe(false);
});
});
});