From 41e9883daa4cd8c63e92097e8856a11d93923325 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 20 Aug 2026 22:36:50 +0800 Subject: [PATCH] test(recipe): cover send-workflow frontend paths --- .../api/recipeApi.sendWorkflow.test.js | 116 ++++++++ .../recipeModal.sendWorkflow.test.js | 251 ++++++++++++++++++ tests/frontend/core/workflowRegistry.test.js | 109 ++++++++ 3 files changed, 476 insertions(+) create mode 100644 tests/frontend/api/recipeApi.sendWorkflow.test.js create mode 100644 tests/frontend/components/recipeModal.sendWorkflow.test.js diff --git a/tests/frontend/api/recipeApi.sendWorkflow.test.js b/tests/frontend/api/recipeApi.sendWorkflow.test.js new file mode 100644 index 00000000..0796ef2e --- /dev/null +++ b/tests/frontend/api/recipeApi.sendWorkflow.test.js @@ -0,0 +1,116 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.hoisted(() => vi.fn()); +const loadingManagerMock = vi.hoisted(() => ({ + showSimpleLoading: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + restoreProgressBar: vi.fn(), +})); +const virtualScrollerMock = vi.hoisted(() => ({ + updateSingleItem: vi.fn(), + refreshWithData: vi.fn(), +})); +const getCurrentPageStateMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => { + return { + showToast: showToastMock, + }; +}); + +vi.mock('../../../static/js/components/RecipeCard.js', () => ({ + RecipeCard: vi.fn(() => ({ element: document.createElement('div') })), +})); + +vi.mock('../../../static/js/state/index.js', () => { + return { + state: { + loadingManager: loadingManagerMock, + virtualScroller: virtualScrollerMock, + }, + getCurrentPageState: getCurrentPageStateMock, + }; +}); + +vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ + captureScrollPosition: vi.fn(), + restoreScrollPosition: vi.fn(), + recreateVirtualScroll: vi.fn(), +})); + +import { sendRecipeWorkflow } from '../../../static/js/api/recipeApi.js'; + +describe('sendRecipeWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + getCurrentPageStateMock.mockReturnValue({}); + }); + + afterEach(() => { + delete global.fetch; + }); + + it('posts to the send-workflow endpoint and returns the parsed result', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + const result = await sendRecipeWorkflow('recipe-1'); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipe/recipe-1/send-workflow', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + } + ); + expect(result).toEqual({ success: true }); + }); + + it('returns the backend error when the response is not ok', async () => { + global.fetch.mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: async () => ({ success: false, error: 'Standalone Mode Active' }), + }); + + const result = await sendRecipeWorkflow('recipe-1'); + + expect(result).toEqual({ success: false, error: 'Standalone Mode Active' }); + }); + + it('falls back to statusText when the error payload has no error field', async () => { + global.fetch.mockResolvedValue({ + ok: false, + statusText: 'Bad Gateway', + json: async () => ({}), + }); + + const result = await sendRecipeWorkflow('recipe-1'); + + expect(result).toEqual({ success: false, error: 'Bad Gateway' }); + }); + + it('throws when the recipe ID cannot be determined', async () => { + await expect(sendRecipeWorkflow('')).rejects.toThrow('Unable to determine recipe ID'); + await expect(sendRecipeWorkflow(null)).rejects.toThrow('Unable to determine recipe ID'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('encodes the recipe ID in the request URL', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await sendRecipeWorkflow('recipe#1?name=foo%bar'); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar/send-workflow', + expect.objectContaining({ method: 'POST' }) + ); + }); +}); diff --git a/tests/frontend/components/recipeModal.sendWorkflow.test.js b/tests/frontend/components/recipeModal.sendWorkflow.test.js new file mode 100644 index 00000000..be267350 --- /dev/null +++ b/tests/frontend/components/recipeModal.sendWorkflow.test.js @@ -0,0 +1,251 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const stateStub = { + global: { settings: {}, loadingManager: loadingManagerStub }, + loadingManager: loadingManagerStub, + virtualScroller: { updateSingleItem: vi.fn() }, +}; + +const sendRecipeWorkflowMock = vi.fn(); +const fetchRecipeDetailsMock = vi.fn(); +const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true })); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendModelPathToWorkflow: vi.fn(), + openCivitaiByMetadata: vi.fn(), + stripLoraTags: vi.fn((text) => text), + sendPromptToWorkflow: vi.fn(), + sendGenParamsToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + getStorageItem: vi.fn(() => null), + setStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipeDetails: fetchRecipeDetailsMock, + updateRecipeMetadata: updateRecipeMetadataMock, + sendRecipeWorkflow: sendRecipeWorkflowMock, +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings', + }, +})); + +async function flushAsyncTasks() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function createRecipeModal() { + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + return new RecipeModal(); +} + +describe('RecipeModal send workflow to ComfyUI', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ` +
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('syncHeaderActions', () => { + it('inserts the send-workflow button when the recipe embeds a workflow', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.currentRecipe = { has_workflow: true }; + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockResolvedValue({ success: true }); + const sendSpy = vi.spyOn(recipeModal, 'sendWorkflowToComfyUI'); + + recipeModal.syncHeaderActions(); + + const button = document.getElementById('sendWorkflowBtn'); + expect(button).not.toBeNull(); + expect(button.classList.contains('recipe-source-url-btn')).toBe(true); + + button.dispatchEvent(new Event('click', { bubbles: true })); + await flushAsyncTasks(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendRecipeWorkflowMock).toHaveBeenCalledWith('recipe-1'); + }); + + it('does not insert the send-workflow button when has_workflow is not true', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.currentRecipe = { has_workflow: false }; + + recipeModal.syncHeaderActions(); + + expect(document.getElementById('sendWorkflowBtn')).toBeNull(); + + recipeModal.currentRecipe = {}; + recipeModal.syncHeaderActions(); + + expect(document.getElementById('sendWorkflowBtn')).toBeNull(); + }); + + it('clears previously injected buttons on every call', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.currentRecipe = { + has_workflow: true, + source_path: 'https://civitai.com/models/123', + }; + + recipeModal.syncHeaderActions(); + recipeModal.syncHeaderActions(); + + const buttons = document.querySelectorAll('#recipeHeaderActions .recipe-source-url-btn'); + expect(buttons).toHaveLength(2); + expect(document.querySelectorAll('#sendWorkflowBtn')).toHaveLength(1); + }); + + it('inserts the Open Source URL button for http(s) source paths', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.currentRecipe = { source_path: 'https://civitai.com/models/123' }; + + recipeModal.syncHeaderActions(); + + const urlButton = document.querySelector('#recipeHeaderActions .recipe-source-url-btn'); + expect(urlButton).not.toBeNull(); + expect(urlButton.id).not.toBe('sendWorkflowBtn'); + expect(urlButton.title).toBe('https://civitai.com/models/123'); + + recipeModal.currentRecipe = { source_path: '/local/path/recipe.webp' }; + recipeModal.syncHeaderActions(); + + expect(document.querySelector('#recipeHeaderActions .recipe-source-url-btn')).toBeNull(); + }); + }); + + describe('sendWorkflowToComfyUI', () => { + it('shows a success toast when the workflow is sent', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockResolvedValue({ success: true }); + + await recipeModal.sendWorkflowToComfyUI(); + + expect(sendRecipeWorkflowMock).toHaveBeenCalledWith('recipe-1'); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.workflowSent', + {}, + 'success', + 'Workflow sent to ComfyUI' + ); + }); + + it('shows a warning toast in standalone mode', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockResolvedValue({ + success: false, + error: 'Standalone Mode Active', + }); + + await recipeModal.sendWorkflowToComfyUI(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.general.cannotInteractStandalone', + {}, + 'warning', + 'Cannot interact with ComfyUI in standalone mode' + ); + }); + + it('shows a warning toast when the recipe has no embedded workflow', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockResolvedValue({ + success: false, + error: 'no_workflow', + }); + + await recipeModal.sendWorkflowToComfyUI(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.workflowNoWorkflow', + {}, + 'warning', + 'No embedded workflow found in this recipe' + ); + }); + + it('shows an error toast for other backend errors', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockResolvedValue({ + success: false, + error: 'ComfyUI unreachable', + }); + + await recipeModal.sendWorkflowToComfyUI(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.workflowSendFailed', + { error: 'ComfyUI unreachable' }, + 'error', + 'Failed to send workflow to ComfyUI: ComfyUI unreachable' + ); + }); + + it('shows an error toast when the API call throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = 'recipe-1'; + sendRecipeWorkflowMock.mockRejectedValue(new Error('network down')); + + await recipeModal.sendWorkflowToComfyUI(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.workflowSendFailed', + { error: 'network down' }, + 'error', + 'Failed to send workflow to ComfyUI: network down' + ); + errorSpy.mockRestore(); + }); + + it('does not call the API without a recipe ID', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.recipeId = null; + + await recipeModal.sendWorkflowToComfyUI(); + + expect(sendRecipeWorkflowMock).not.toHaveBeenCalled(); + expect(showToastMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/frontend/core/workflowRegistry.test.js b/tests/frontend/core/workflowRegistry.test.js index 34ac8bde..c05a5798 100644 --- a/tests/frontend/core/workflowRegistry.test.js +++ b/tests/frontend/core/workflowRegistry.test.js @@ -211,6 +211,115 @@ describe("LoraManager.WorkflowRegistry", () => { }); }); + 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: [] });