mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
1b5cbbbaa0
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched entries can be undone via the existing restore flow - Bulk missing-LoRA downloads mark unresolvable failures hash-invalid, flipping those entries from download to reconnect candidacy - Recipe modal always offers a reconnect action next to download for missing LoRA entries - Rematch runs collect an opt-in relaxed-matching choice (also reconnect missing models by file name) via a pre-run options dialog on the global, bulk and single-recipe entries - L4 (filename-level) matches are listed in a results dialog with per-entry undo
193 lines
6.8 KiB
JavaScript
193 lines
6.8 KiB
JavaScript
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
|
|
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
|
|
|
|
const showToastMock = vi.fn();
|
|
const updateProgressMock = vi.fn();
|
|
const updateSingleItemMock = vi.fn();
|
|
|
|
const mockApiClient = {
|
|
downloadModel: vi.fn(),
|
|
cancelDownload: vi.fn(),
|
|
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
|
|
};
|
|
|
|
const loadingManagerStub = {
|
|
showDownloadProgress: vi.fn(() => updateProgressMock),
|
|
setStatus: vi.fn(),
|
|
showCancelButton: vi.fn(),
|
|
hide: vi.fn(),
|
|
};
|
|
|
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|
showToast: showToastMock,
|
|
// Keep the real predicate: these tests assert on its classification.
|
|
isUnresolvableDownloadError: (message) =>
|
|
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
|
getModelApiClient: vi.fn(() => mockApiClient),
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
|
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
|
}));
|
|
|
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
|
extractRecipeId: (filePath) => {
|
|
if (!filePath) return null;
|
|
const basename = filePath.split('/').pop().split('\\').pop();
|
|
const dotIndex = basename.lastIndexOf('.');
|
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../static/js/state/index.js', () => ({
|
|
state: {
|
|
loadingManager: loadingManagerStub,
|
|
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
|
global: { settings: {} },
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
|
}));
|
|
|
|
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
|
|
class FakeWebSocket {
|
|
static instances = [];
|
|
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.onopen = null;
|
|
this.onmessage = null;
|
|
this.onerror = null;
|
|
this.close = vi.fn();
|
|
FakeWebSocket.instances.push(this);
|
|
queueMicrotask(() => {
|
|
if (this.onopen) this.onopen();
|
|
});
|
|
}
|
|
}
|
|
|
|
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
|
|
|
|
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
|
|
let manager;
|
|
let fetchMock;
|
|
let requests;
|
|
|
|
beforeEach(async () => {
|
|
FakeWebSocket.instances = [];
|
|
vi.clearAllMocks();
|
|
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
|
|
|
|
requests = [];
|
|
fetchMock = vi.fn((url, options) => {
|
|
requests.push({ url, options });
|
|
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
|
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
}
|
|
// Recipe detail refresh after the download loop
|
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
|
|
vi.resetModules();
|
|
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
|
|
manager.pendingLoras = [];
|
|
manager.pendingRecipes = [];
|
|
manager.pendingMissingByRecipe = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const primePending = (recipes) => {
|
|
const stats = manager.collectMissingLoras(recipes);
|
|
manager.pendingRecipes = recipes;
|
|
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
|
return stats.uniqueLoras;
|
|
};
|
|
|
|
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
|
|
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
|
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
|
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
|
|
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
|
|
const uniqueLoras = primePending([recipe1, recipe2]);
|
|
|
|
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
|
|
|
|
await manager.executeDownload(uniqueLoras);
|
|
|
|
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
|
expect(markCalls).toHaveLength(2);
|
|
const payloads = markCalls.map(r => JSON.parse(r.options.body));
|
|
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
|
|
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
|
|
expect(entryA.hashInvalid).toBe(true);
|
|
expect(entryB.hashInvalid).toBe(true);
|
|
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'toast.recipes.unresolvableMarkedForReconnect',
|
|
{ count: 2 },
|
|
'info',
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('leaves entries untouched when the failure is transient', async () => {
|
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
|
const uniqueLoras = primePending([recipe]);
|
|
|
|
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
|
|
|
|
await manager.executeDownload(uniqueLoras);
|
|
|
|
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
|
expect(entry.hashInvalid).toBeUndefined();
|
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
|
'toast.recipes.unresolvableMarkedForReconnect',
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
|
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
|
const uniqueLoras = primePending([recipe]);
|
|
|
|
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
|
|
|
|
await manager.executeDownload(uniqueLoras);
|
|
|
|
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
|
expect(markCalls).toHaveLength(1);
|
|
expect(entry.hashInvalid).toBe(true);
|
|
});
|
|
|
|
it('does not mark entries whose download succeeds', async () => {
|
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
|
const uniqueLoras = primePending([recipe]);
|
|
|
|
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
|
|
|
await manager.executeDownload(uniqueLoras);
|
|
|
|
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
|
});
|
|
});
|