mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
a17399d667
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.
- POST /api/lm/recipe/{id}/reimport accepts optional payload params
(image_url/name/resources/gen_params/base_model/tags); the payload path
reuses the import-remote engine with reimport semantics (user-edit
carryover, delete-after-save), and malformed/failed payloads fall back
to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
(documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
all 9 locales.
182 lines
5.8 KiB
JavaScript
182 lines
5.8 KiB
JavaScript
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
|
|
const showToastMock = vi.fn();
|
|
const showSimpleLoadingMock = vi.fn();
|
|
const hideLoadingMock = vi.fn();
|
|
const resetAndReloadMock = vi.fn();
|
|
const probeExtensionMock = vi.fn();
|
|
const delegateReimportMock = vi.fn();
|
|
|
|
const stateStub = {
|
|
virtualScroller: { items: [] },
|
|
loadingManager: {
|
|
showSimpleLoading: showSimpleLoadingMock,
|
|
hide: hideLoadingMock,
|
|
},
|
|
};
|
|
|
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|
showToast: showToastMock,
|
|
copyToClipboard: vi.fn(),
|
|
sendLoraToWorkflow: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
|
setSessionItem: vi.fn(),
|
|
removeSessionItem: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
|
updateRecipeMetadata: vi.fn(),
|
|
resetAndReload: resetAndReloadMock,
|
|
}));
|
|
|
|
vi.mock('../../../static/js/state/index.js', () => ({
|
|
state: stateStub,
|
|
}));
|
|
|
|
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
|
moveManager: { showMoveModal: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
|
|
ModelContextMenuMixin: {
|
|
handleCommonMenuActions: vi.fn(() => false),
|
|
initNSFWSelector: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Keep the real getCivitaiImageInfo (gating logic under test); mock only the
|
|
// extension communication.
|
|
vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
probeExtension: probeExtensionMock,
|
|
delegateReimport: delegateReimportMock,
|
|
};
|
|
});
|
|
|
|
describe('RecipeContextMenu.reimportRecipe extension delegation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
document.body.innerHTML = `
|
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
|
<div class="context-menu-item" data-action="reimport"></div>
|
|
</div>
|
|
`;
|
|
stateStub.virtualScroller.items = [
|
|
{
|
|
id: 'recipe-1',
|
|
file_path: '/recipes/recipe-1.webp',
|
|
title: 'Civitai Recipe',
|
|
source_path: 'https://civitai.com/images/12345',
|
|
},
|
|
{
|
|
id: 'recipe-2',
|
|
file_path: '/recipes/recipe-2.webp',
|
|
title: 'Local Recipe',
|
|
source_path: '/data/imports/local.png',
|
|
},
|
|
];
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.fetch;
|
|
});
|
|
|
|
async function createMenu() {
|
|
const { RecipeContextMenu } = await import(
|
|
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
|
|
);
|
|
return new RecipeContextMenu();
|
|
}
|
|
|
|
it('delegates to the extension for a CivitAI image source when licensed', async () => {
|
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
|
delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 });
|
|
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-1');
|
|
|
|
expect(delegateReimportMock).toHaveBeenCalledWith([{
|
|
recipeId: 'recipe-1',
|
|
imageId: 12345,
|
|
imageUrl: 'https://civitai.com/images/12345',
|
|
title: 'Civitai Recipe',
|
|
}]);
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
|
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
|
|
});
|
|
|
|
it('shows the failure toast when the extension reports failures', async () => {
|
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
|
delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 });
|
|
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-1');
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'recipes.contextMenu.reimport.failed',
|
|
{ message: 'Extension re-import failed' },
|
|
'error'
|
|
);
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
|
|
});
|
|
|
|
it('uses the native path for non-CivitAI sources without probing', async () => {
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-2');
|
|
|
|
expect(probeExtensionMock).not.toHaveBeenCalled();
|
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', {
|
|
method: 'POST',
|
|
});
|
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
|
});
|
|
|
|
it('uses the native path when the extension is absent (probe timeout)', async () => {
|
|
probeExtensionMock.mockResolvedValue(null);
|
|
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-1');
|
|
|
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
|
method: 'POST',
|
|
});
|
|
});
|
|
|
|
it('uses the native path when the license is invalid', async () => {
|
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false });
|
|
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-1');
|
|
|
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
|
method: 'POST',
|
|
});
|
|
});
|
|
|
|
it('falls back to the native path when delegation fails', async () => {
|
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
|
delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out'));
|
|
|
|
const menu = await createMenu();
|
|
await menu.reimportRecipe('recipe-1');
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
|
method: 'POST',
|
|
});
|
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
|
});
|
|
});
|