Files
ComfyUI-Lora-Manager/tests/frontend/utils/saveRecipeDirectly.test.js
T
Will Miao faeb66a23d feat(recipes): opt-in workflow embedding for widget recipe saves
Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.

- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
  UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
  long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
  the existing optimize_image workflow path, derives has_workflow by detection,
  and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
2026-09-29 09:03:26 +08:00

93 lines
2.8 KiB
JavaScript

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const { UTILS_MODULE, APP_MODULE, API_MODULE, BASE_PATH_MODULE } = vi.hoisted(() => ({
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
BASE_PATH_MODULE: new URL('../../../web/comfyui/base_path.js', import.meta.url).pathname,
}));
const toastAdd = vi.fn();
const graphToPrompt = vi.fn();
vi.mock(APP_MODULE, () => ({
app: {
graphToPrompt,
extensionManager: { toast: { add: toastAdd } },
},
}));
vi.mock(API_MODULE, () => ({
api: { fetchApi: vi.fn() },
}));
vi.mock(BASE_PATH_MODULE, () => ({
lmUrl: (path) => `/lm${path}`,
}));
async function runSave(options, responseBody) {
let captured = null;
globalThis.fetch = vi.fn(async (url, init) => {
captured = { url, init };
return { json: async () => responseBody };
});
const { saveRecipeDirectly } = await import(UTILS_MODULE);
await saveRecipeDirectly(options);
return captured;
}
describe('saveRecipeDirectly', () => {
beforeEach(() => {
graphToPrompt.mockResolvedValue({
workflow: { nodes: [{ id: 1 }], last_node_id: 1 },
output: { 1: { class_type: 'KSampler' } },
});
});
afterEach(() => {
delete globalThis.fetch;
vi.clearAllMocks();
});
it('posts no workflow by default', async () => {
const captured = await runSave(undefined, { success: true, has_workflow: false });
expect(captured.init.body).toBe('{}');
expect(JSON.parse(captured.init.body)).not.toHaveProperty('workflow');
});
it('embeds the UI-format graph when asked', async () => {
const captured = await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: true }
);
const body = JSON.parse(captured.init.body);
expect(body.workflow).toEqual({ nodes: [{ id: 1 }], last_node_id: 1 });
expect(captured.init.headers['Content-Type']).toBe('application/json');
});
it('reports a skipped oversized workflow as a warning', async () => {
await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: false, workflow_skipped: 'too_large' }
);
const lastToast = toastAdd.mock.calls.at(-1)[0];
expect(lastToast.severity).toBe('warn');
expect(lastToast.summary).toBe('Recipe Saved without Workflow');
expect(lastToast.detail).toContain('too large');
});
it('reports a successful embed distinctly from a plain save', async () => {
await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: true }
);
const lastToast = toastAdd.mock.calls.at(-1)[0];
expect(lastToast.summary).toBe('Recipe Saved with Workflow');
});
});