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 = `
`; } 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 = `
Other Flow
My Flow
`; 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); }); }); });