mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-21 13:01:27 -03:00
test(recipe): cover send-workflow frontend paths
This commit is contained in:
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = `
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
|
||||
Reference in New Issue
Block a user