import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); const translateMock = vi.fn((key, params, fallback) => { if (typeof fallback !== 'string') { return key; } return fallback.replace(/\{(\w+)\}/g, (match, name) => params && params[name] != null ? String(params[name]) : match ); }); const loadingManagerStub = { showSimpleLoading: vi.fn(), hide: vi.fn(), show: vi.fn(), restoreProgressBar: vi.fn(), }; const virtualScrollerStub = { updateSingleItem: vi.fn(), getNavigationState: vi.fn(() => ({ index: 0, hasPrev: false, hasNext: false, loadedItems: 1, totalItems: 1, })), getAdjacentItemByFilePath: vi.fn(async () => null), }; const stateStub = { global: { settings: {}, loadingManager: loadingManagerStub }, loadingManager: loadingManagerStub, virtualScroller: virtualScrollerStub, }; const modalManagerMock = { showModal: vi.fn(), closeModal: vi.fn(), }; const downloadVersionWithDefaultsMock = vi.fn(() => Promise.resolve()); 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(), getSessionItem: vi.fn(() => null), getStorageItem: vi.fn(() => null), setStorageItem: vi.fn(), removeStorageItem: vi.fn(), })); const fetchRecipeDetailsMock = vi.fn(() => Promise.resolve({})); vi.mock('../../../static/js/api/recipeApi.js', () => ({ fetchRecipeDetails: fetchRecipeDetailsMock, updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })), sendRecipeWorkflow: vi.fn(), 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/api/apiConfig.js', () => ({ MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', }, })); const downloadManagerMock = { downloadVersionWithDefaults: downloadVersionWithDefaultsMock, _lastDownloadError: '', }; vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ downloadManager: downloadManagerMock, })); function recipeModalFixture() { return ` `; } const missingLora = { name: 'gone-lora', modelName: 'Gone LoRA', inLibrary: false, modelId: 123, id: 456, }; const hashOnlyLora = { name: 'hash-lora', modelName: 'Hash LoRA', inLibrary: false, hash: 'deadbeefcafe', }; const hashInvalidLora = { name: 'invalid-hash-lora', modelName: 'Invalid Hash LoRA', inLibrary: false, hash: 'a2a12bfa01', hashInvalid: true, }; const recipeWithResources = { id: 'recipe-resources', file_path: '/recipes/resources.json', title: 'Resource Recipe', tags: [], checkpoint: { name: 'gone-checkpoint', inLibrary: false, modelId: 900, id: 901, }, loras: [ { name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' }, missingLora, { name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true }, hashInvalidLora, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, hashOnlyLora, ], }; const createdModals = []; async function createRecipeModal() { const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); const recipeModal = new RecipeModal(); createdModals.push(recipeModal); return recipeModal; } function flushWiring() { // Item action/navigation wiring happens in a setTimeout after rendering return new Promise(resolve => setTimeout(resolve, 150)); } describe('RecipeModal resource item interactions', () => { beforeEach(() => { vi.clearAllMocks(); // clearAllMocks keeps one-off mockResolvedValue implementations; reset // the shared mocks to their defaults explicitly. downloadVersionWithDefaultsMock.mockReset(); downloadVersionWithDefaultsMock.mockResolvedValue(undefined); downloadManagerMock._lastDownloadError = ''; fetchRecipeDetailsMock.mockReset(); // Hydration re-fetches the recipe right after render; resolving an empty // object would delete currentRecipe.loras and wipe the list, so resolve // the full recipe by default (individual tests can override afterwards). fetchRecipeDetailsMock.mockResolvedValue(recipeWithResources); document.body.innerHTML = recipeModalFixture(); global.modalManager = modalManagerMock; global.fetch = vi.fn(async () => ({ ok: true, json: async () => ({}), })); }); afterEach(() => { // dispose() marks each modal instance dead: pending deferred timers // (wiring, 500ms reconnect re-renders) are cancelled and in-flight // async chains become no-ops, so nothing from this test can touch the // DOM of the next one. createdModals.forEach(recipeModal => recipeModal.dispose()); createdModals.length = 0; document.body.innerHTML = ''; delete global.modalManager; delete global.fetch; }); it('renders the missing badge as a pure status indicator with a tooltip', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); const badge = document.querySelector('.recipe-lora-item.missing-locally .missing-badge'); expect(badge).not.toBeNull(); expect(badge.tagName).toBe('DIV'); expect(badge.title).toBe('This model is not in your library'); expect(badge.classList.contains('reconnectable')).toBe(false); expect(badge.textContent).toContain('Not in Library'); }); it('renders an explicit download action and an inline title link for a missing LoRA', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); const item = document.querySelector('.recipe-lora-item.missing-locally:not(.checkpoint-item)'); // The Civitai link belongs to the title, not the action row const link = item.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.target).toBe('_blank'); expect(link.rel).toContain('noopener'); expect(link.href).toBe('https://civitai.com/models/123?modelVersionId=456'); expect(link.title).toBe('View on Civitai'); const actions = item.querySelector('.recipe-lora-actions'); expect(actions).not.toBeNull(); const downloadButton = actions.querySelector('.lora-download'); expect(downloadButton).not.toBeNull(); expect(downloadButton.tagName).toBe('BUTTON'); expect(downloadButton.type).toBe('button'); expect(downloadButton.classList.contains('resource-action')).toBe(true); expect(downloadButton.classList.contains('primary')).toBe(true); expect(downloadButton.title).toBe('Download this LoRA'); // The action row holds only real actions; no link icon in it expect(actions.querySelector('.recipe-civitai-link')).toBeNull(); }); it('downloads only the clicked LoRA via the download manager', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const downloadButton = document.querySelector('.lora-download'); downloadButton.click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith( 'loras', 123, 456, expect.objectContaining({ source: 'recipe-modal' }) ); }); it('does not navigate when a missing LoRA row is clicked', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToLorasPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const missingItem = document.querySelector('.recipe-lora-item.missing-locally'); expect(missingItem.getAttribute('role')).toBeNull(); expect(missingItem.getAttribute('tabindex')).toBeNull(); missingItem.click(); expect(navigateSpy).not.toHaveBeenCalled(); }); it('keeps in-library rows navigable and keyboard accessible', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToLorasPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const localItem = document.querySelector('.recipe-lora-item.exists-locally:not(.checkpoint-item)'); expect(localItem.getAttribute('role')).toBe('button'); expect(localItem.getAttribute('tabindex')).toBe('0'); localItem.click(); expect(navigateSpy).toHaveBeenCalledWith(0); navigateSpy.mockClear(); localItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(navigateSpy).toHaveBeenCalledWith(0); }); it('renders deleted LoRAs with a status badge and an explicit reconnect button', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const deletedItem = document.querySelector('.recipe-lora-item.is-deleted'); const badge = deletedItem.querySelector('.deleted-badge'); expect(badge).not.toBeNull(); expect(badge.classList.contains('reconnectable')).toBe(false); expect(badge.title).toContain('deleted from the source'); const reconnectButton = deletedItem.querySelector('.lora-reconnect'); expect(reconnectButton).not.toBeNull(); expect(reconnectButton.tagName).toBe('BUTTON'); expect(reconnectButton.classList.contains('ghost')).toBe(true); reconnectButton.click(); const container = deletedItem.querySelector('.lora-reconnect-container'); expect(container.classList.contains('active')).toBe(true); }); it('shows reconnect failures inline in the panel instead of a toast', async () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => { if (String(url).includes('/recipe/lora/reconnect')) { return { ok: true, json: async () => ({ success: false, error: 'LoRA not found locally' }) }; } return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const deletedItem = document.querySelector('.recipe-lora-item.is-deleted'); deletedItem.querySelector('.lora-reconnect').click(); const container = deletedItem.querySelector('.lora-reconnect-container'); const input = container.querySelector('.reconnect-input'); const error = container.querySelector('.reconnect-error'); expect(error).not.toBeNull(); input.value = 'nonexistent-lora'; container.querySelector('.reconnect-confirm-btn').click(); await vi.waitFor(() => { expect(error.classList.contains('active')).toBe(true); }); expect(error.textContent).toContain('LoRA not found locally'); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.reconnectFailed', expect.anything(), 'error' ); // Typing again clears the inline error input.dispatchEvent(new Event('input', { bubbles: true })); expect(error.classList.contains('active')).toBe(false); expect(error.textContent).toBe(''); }); it('renders hash-invalid LoRAs with a dedicated badge and reconnect instead of download', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const invalidItem = document.querySelector('[data-lora-index="3"]'); const badge = invalidItem.querySelector('.invalid-hash-badge'); expect(badge).not.toBeNull(); expect(badge.title).toContain('cannot be resolved on CivitAI'); expect(badge.textContent).toContain('Unresolvable Hash'); expect(invalidItem.querySelector('.lora-download')).toBeNull(); const reconnectButton = invalidItem.querySelector('.lora-reconnect'); expect(reconnectButton).not.toBeNull(); reconnectButton.click(); const container = invalidItem.querySelector('.lora-reconnect-container'); expect(container).not.toBeNull(); expect(container.classList.contains('active')).toBe(true); }); it('marks the entry hash-invalid when hash resolution returns Model not found', async () => { const recipeModal = await createRecipeModal(); const requests = []; // Deep copy so the mark step mutating loras[5].hashInvalid does not // leak into the shared fixture used by later tests. const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); const urlStr = String(url); if (urlStr.includes('/civitai/model/hash/')) { return { ok: false, json: async () => ({ success: false, error: 'Model not found' }) }; } return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); const hashItem = document.querySelector('[data-lora-index="5"]'); const downloadButton = hashItem.querySelector('.lora-download'); downloadButton.click(); await vi.waitFor(() => { expect( requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid')) ).toBe(true); }); const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid')); expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', lora_index: 5, }); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.hashNotFoundOnCivitai', {}, 'error' ); expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled(); }); it('offers reconnect for name-only LoRAs with no CivitAI identifiers', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const mysteryItem = document.querySelector('[data-lora-index="4"]'); expect(mysteryItem.querySelector('.lora-download')).toBeNull(); const reconnectButton = mysteryItem.querySelector('.lora-reconnect'); expect(reconnectButton).not.toBeNull(); reconnectButton.click(); const container = mysteryItem.querySelector('.lora-reconnect-container'); expect(container).not.toBeNull(); expect(container.classList.contains('active')).toBe(true); // The name-fallback search link still sits inline in the title const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.href).toContain('query=Mystery%20LoRA'); }); it('marks the entry hash-invalid when a direct download fails with an unresolvable error', async () => { const recipeModal = await createRecipeModal(); const requests = []; // Deep copy so the mark step mutating loras[1].hashInvalid does not // leak into the shared fixture used by later tests. const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); downloadManagerMock._lastDownloadError = 'Model not found'; downloadVersionWithDefaultsMock.mockResolvedValue(false); global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); // missingLora carries direct identifiers, so no hash-resolution round // trip happens before the download attempt. const missingItem = document.querySelector('[data-lora-index="1"]'); missingItem.querySelector('.lora-download').click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); await vi.waitFor(() => { expect( requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid')) ).toBe(true); }); const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid')); expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', lora_index: 1, }); // The re-rendered entry swaps the download action for the reconnect one await vi.waitFor(() => { const item = document.querySelector('[data-lora-index="1"]'); expect(item.querySelector('.lora-reconnect')).not.toBeNull(); expect(item.querySelector('.lora-download')).toBeNull(); expect(item.querySelector('.invalid-hash-badge')).not.toBeNull(); }); }); it('leaves the entry untouched when a direct download fails transiently', async () => { const recipeModal = await createRecipeModal(); const requests = []; const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); downloadManagerMock._lastDownloadError = 'Connection timed out'; downloadVersionWithDefaultsMock.mockResolvedValue(false); global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); const missingItem = document.querySelector('[data-lora-index="1"]'); missingItem.querySelector('.lora-download').click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); // Give any (unexpected) mark request a chance to fire await new Promise(resolve => setTimeout(resolve, 50)); expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false); // The entry keeps the download action and never flips to reconnect const item = document.querySelector('[data-lora-index="1"]'); expect(item.querySelector('.lora-download')).not.toBeNull(); expect(item.querySelector('.lora-reconnect')).toBeNull(); }); it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => ({ ok: true, json: async () => typeof url === 'string' && url.includes('/civitai/model/hash/') ? { id: 789, modelId: 777, name: 'Hash LoRA v1' } : {}, })); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const hashItem = document.querySelector('[data-lora-index="5"]'); const downloadButton = hashItem.querySelector('.lora-download'); expect(downloadButton).not.toBeNull(); downloadButton.click(); await vi.waitFor(() => { expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); }); // Hash resolution needs a network round trip; immediate feedback must // appear while the user waits for the progress UI expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Preparing download...'); expect(loadingManagerStub.hide).toHaveBeenCalled(); expect(global.fetch).toHaveBeenCalledWith( '/api/lm/loras/civitai/model/hash/deadbeefcafe' ); expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith( 'loras', 777, 789, expect.objectContaining({ source: 'recipe-modal', versionName: 'Hash LoRA v1' }) ); }); it('refreshes the resources section and the recipe card after a successful download', async () => { const recipeModal = await createRecipeModal(); downloadVersionWithDefaultsMock.mockResolvedValue(true); const updatedRecipe = { ...recipeWithResources, loras: recipeWithResources.loras.map((lora, index) => index === 1 ? { ...lora, inLibrary: true } : lora ), }; recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); fetchRecipeDetailsMock.mockResolvedValue(updatedRecipe); const downloadButton = document.querySelector('.lora-download'); downloadButton.click(); await vi.waitFor(() => { expect(fetchRecipeDetailsMock).toHaveBeenCalledWith('recipe-resources'); }); // The recipe card on the listing page receives the fresh recipe data await vi.waitFor(() => { expect(virtualScrollerStub.updateSingleItem).toHaveBeenCalledWith( '/recipes/resources.json', updatedRecipe ); }); // The modal's resources section re-renders with the flipped status await vi.waitFor(() => { const item = document.querySelector('[data-lora-index="1"]'); expect(item.classList.contains('exists-locally')).toBe(true); expect(item.querySelector('.local-badge')).not.toBeNull(); expect(item.querySelector('.missing-badge')).toBeNull(); }); }); it('shows a status badge, download action and Civitai link for a missing checkpoint without row navigation', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi .spyOn(recipeModal, 'navigateToCheckpointPage') .mockImplementation(() => {}); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const checkpointItem = document.querySelector('.checkpoint-item'); expect(checkpointItem.classList.contains('missing-locally')).toBe(true); expect(checkpointItem.getAttribute('role')).toBeNull(); expect(checkpointItem.querySelector('.missing-badge')).not.toBeNull(); expect(checkpointItem.querySelector('.checkpoint-download')).not.toBeNull(); const link = checkpointItem.querySelector('.recipe-lora-title a.recipe-civitai-link'); expect(link).not.toBeNull(); expect(link.href).toBe('https://civitai.com/models/900?modelVersionId=901'); checkpointItem.click(); expect(navigateSpy).not.toHaveBeenCalled(); }); describe('reconnect suggestions', () => { const suggestionsPayload = { success: true, suggestions: [ { file_name: 'deleted-lora-v1.safetensors', file_path: '/models/loras/deleted-lora-v1.safetensors', model_name: 'Deleted LoRA v1', base_model: 'SD 1.5', preview_url: '/preview/deleted.png', hash: 'abc123', score: 0.95, match_reason: 'same_version', target_name: 'deleted-lora-v1', }, ], }; function mockSuggestionsFetch(payload) { const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); if (String(url).includes('/reconnect-suggestions')) { return { ok: true, json: async () => payload }; } if (String(url).includes('/recipe/lora/reconnect')) { return { ok: true, json: async () => ({ success: true, updated_lora: { name: 'deleted-lora-v1', modelName: 'Deleted LoRA v1', inLibrary: true }, }), }; } return { ok: true, json: async () => ({}) }; }); return requests; } async function openReconnectPanel(recipeModal, loraIndex) { recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); const item = document.querySelector(`[data-lora-index="${loraIndex}"]`); item.querySelector('.lora-reconnect').click(); return item.querySelector('.lora-reconnect-container'); } it('fetches suggestions when the panel opens and renders them as rows', async () => { const recipeModal = await createRecipeModal(); mockSuggestionsFetch(suggestionsPayload); const container = await openReconnectPanel(recipeModal, 2); // The loading state shows synchronously while the fetch is in flight expect(container.querySelector('.reconnect-suggestions-loading')).not.toBeNull(); await vi.waitFor(() => { expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); }); expect(global.fetch).toHaveBeenCalledWith( '/api/lm/recipe/recipe-resources/lora/2/reconnect-suggestions' ); const row = container.querySelector('.reconnect-suggestion'); // Primary label is the file stem (what the match scored on and what // gets submitted); the secondary line shows only the base model — the // model name is noise and intentionally omitted. expect(row.querySelector('.reconnect-suggestion-name').textContent).toBe('deleted-lora-v1'); expect(row.querySelector('.reconnect-suggestion-secondary').textContent).toBe('SD 1.5'); expect(row.querySelector('.reconnect-suggestion-reason').textContent).toBe('Same model version'); expect(row.title).toBe('deleted-lora-v1'); const preview = row.querySelector('.reconnect-suggestion-preview'); expect(preview.getAttribute('src')).toBe('/preview/deleted.png'); }); it('reconnects with the suggestion target_name when a row is clicked', async () => { const recipeModal = await createRecipeModal(); const requests = mockSuggestionsFetch(suggestionsPayload); const container = await openReconnectPanel(recipeModal, 2); await vi.waitFor(() => { expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); }); container.querySelector('.reconnect-suggestion').click(); await vi.waitFor(() => { expect(requests.some(r => r.url === '/api/lm/recipe/lora/reconnect')).toBe(true); }); const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/lora/reconnect'); expect(reconnectRequest.options.method).toBe('POST'); // lora_index rides as the DOM attribute string, same as the manual form expect(JSON.parse(reconnectRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', lora_index: '2', target_name: 'deleted-lora-v1', }); }); it('warns when the reconnect crossed base-model families', async () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => { if (String(url).includes('/reconnect-suggestions')) { return { ok: true, json: async () => suggestionsPayload }; } if (String(url).includes('/recipe/lora/reconnect')) { return { ok: true, json: async () => ({ success: true, updated_lora: { name: 'deleted-lora-v1', modelName: 'Deleted LoRA v1', inLibrary: true }, base_model_mismatch: { recipe_base_model: 'Illustrious', lora_base_model: 'Pony' }, }), }; } return { ok: true, json: async () => ({}) }; }); const container = await openReconnectPanel(recipeModal, 2); await vi.waitFor(() => { expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); }); container.querySelector('.reconnect-suggestion').click(); await vi.waitFor(() => { expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.reconnectBaseModelMismatch', { recipe: 'Illustrious', lora: 'Pony' }, 'warning' ); }); }); it('shows an empty state when no suggestions are available', async () => { const recipeModal = await createRecipeModal(); mockSuggestionsFetch({ success: true, suggestions: [] }); const container = await openReconnectPanel(recipeModal, 3); await vi.waitFor(() => { expect(container.querySelector('.reconnect-suggestions-empty')).not.toBeNull(); }); expect(container.querySelector('.reconnect-suggestions-empty').textContent) .toBe('No matching LoRAs in your local library'); expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(0); }); it('submits free text via the combobox onCommit when Enter is pressed', async () => { const recipeModal = await createRecipeModal(); const requests = mockSuggestionsFetch({ success: true, suggestions: [] }); const container = await openReconnectPanel(recipeModal, 2); const input = container.querySelector('.reconnect-input'); input.value = 'typed-lora-name'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => { expect(requests.some(r => r.url === '/api/lm/recipe/lora/reconnect')).toBe(true); }); const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/lora/reconnect'); expect(JSON.parse(reconnectRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', lora_index: '2', target_name: 'typed-lora-name', }); }); it('keeps the panel open when the combobox dropdown is clicked', async () => { const recipeModal = await createRecipeModal(); mockSuggestionsFetch({ success: true, suggestions: [] }); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); // Open the panel directly — button wiring races the hydration re-render, // and this test is about the document click handler, not the button. recipeModal.showReconnectInput('2'); const container = document.querySelector('.lora-reconnect-container[data-lora-index="2"]'); expect(container.classList.contains('active')).toBe(true); // The dropdown panel lives on document.body; clicking an option there is // part of the reconnect interaction, not an outside click. const panel = document.createElement('div'); panel.className = 'lm-combobox-panel'; document.body.appendChild(panel); panel.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(container.classList.contains('active')).toBe(true); panel.remove(); // A genuine outside click still closes the panel document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(container.classList.contains('active')).toBe(false); }); }); it('offers undo for reconnected entries and restores via the API', async () => { const recipeModal = await createRecipeModal(); const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); isolatedRecipe.loras[0].reconnectSnapshot = { file_name: 'gone', isDeleted: true }; fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); if (String(url).includes('/recipe/lora/restore')) { return { ok: true, json: async () => ({ success: true, updated_lora: { name: 'gone', modelName: 'Gone', inLibrary: false, isDeleted: true }, }), }; } return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); const item = document.querySelector('[data-lora-index="0"]'); const undoButton = item.querySelector('.lora-undo-reconnect'); expect(undoButton).not.toBeNull(); undoButton.click(); // Wait for the whole restore chain (fetch -> json -> toast), not just the // request itself. await vi.waitFor(() => { expect(showToastMock).toHaveBeenCalledWith('toast.recipes.loraRestored', {}, 'success'); }); const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/lora/restore'); expect(restoreRequest.options.method).toBe('POST'); expect(JSON.parse(restoreRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', lora_index: '0', }); }); describe('checkpoint reconnect', () => { const brokenCheckpoint = { name: 'gone-checkpoint', file_name: 'gone', inLibrary: false, isDeleted: true, hash: 'a2a12bfa01', }; const hashInvalidCheckpoint = { name: 'invalid-checkpoint', file_name: 'invalid', inLibrary: false, hashInvalid: true, hash: 'deadbeefcafe', }; function recipeWithCheckpoint(checkpoint) { return { ...JSON.parse(JSON.stringify(recipeWithResources)), checkpoint: { ...checkpoint }, }; } // Hydration re-fetches the recipe right after render and re-renders the // modal, so the mock must resolve the SAME broken-checkpoint recipe — // otherwise the fetch wipes isDeleted/hashInvalid back to the fixture. async function renderBrokenCheckpoint(recipeModal, checkpoint) { const isolated = recipeWithCheckpoint(checkpoint); fetchRecipeDetailsMock.mockResolvedValue(isolated); recipeModal.showRecipeDetails(isolated); await flushWiring(); } function mockCheckpointSuggestionsFetch(payload) { const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); if (String(url).includes('/checkpoint/reconnect-suggestions')) { return { ok: true, json: async () => payload }; } if (String(url).includes('/recipe/checkpoint/reconnect')) { return { ok: true, json: async () => ({ success: true, updated_checkpoint: { name: 'main-checkpoint', file_name: 'main', inLibrary: true, }, }), }; } return { ok: true, json: async () => ({}) }; }); return requests; } it('renders a deleted checkpoint with a badge and reconnect affordance', async () => { const recipeModal = await createRecipeModal(); await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); const item = document.querySelector('.checkpoint-item'); expect(item.classList.contains('is-deleted')).toBe(true); expect(item.querySelector('.deleted-badge')).not.toBeNull(); const reconnectButton = item.querySelector('.checkpoint-reconnect'); expect(reconnectButton).not.toBeNull(); // Deleted checkpoints lose the civitai link (their source page is gone) expect(item.querySelector('.recipe-lora-title a.recipe-civitai-link')).toBeNull(); // The inline form is present but hidden until the button is pressed const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); expect(container).not.toBeNull(); expect(container.classList.contains('active')).toBe(false); }); it('renders a hash-invalid checkpoint with the unresolvable hash badge', async () => { const recipeModal = await createRecipeModal(); await renderBrokenCheckpoint(recipeModal, hashInvalidCheckpoint); const item = document.querySelector('.checkpoint-item'); expect(item.querySelector('.invalid-hash-badge')).not.toBeNull(); expect(item.querySelector('.checkpoint-reconnect')).not.toBeNull(); }); it('renders reconnect for a name-only checkpoint with no download identifiers', async () => { // Importers can leave a checkpoint entry with nothing but a model name // (no hash / version id, so nothing was ever queryable on CivitAI). // It cannot be downloaded and is not marked deleted — reconnect is the // only remediation, so it must still surface. const recipeModal = await createRecipeModal(); await renderBrokenCheckpoint(recipeModal, { type: 'checkpoint', modelName: 'meichidarkMix_meichidarkanimxlV1', inLibrary: false, }); const item = document.querySelector('.checkpoint-item'); expect(item.querySelector('.checkpoint-download')).toBeNull(); const reconnectButton = item.querySelector('.checkpoint-reconnect'); expect(reconnectButton).not.toBeNull(); const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); expect(container).not.toBeNull(); reconnectButton.click(); expect(container.classList.contains('active')).toBe(true); }); it('fetches checkpoint suggestions against the checkpoint endpoint', async () => { const recipeModal = await createRecipeModal(); const suggestionsPayload = { success: true, suggestions: [ { file_name: 'main-checkpoint.safetensors', base_model: 'SD 1.5', preview_url: '/preview/main.png', score: 0.95, match_reason: 'same_version', target_name: 'main-checkpoint', }, ], }; mockCheckpointSuggestionsFetch(suggestionsPayload); await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); document.querySelector('.checkpoint-reconnect').click(); const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); expect(container.classList.contains('active')).toBe(true); expect(global.fetch).toHaveBeenCalledWith( '/api/lm/recipe/recipe-resources/checkpoint/reconnect-suggestions' ); await vi.waitFor(() => { expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); }); expect(container.querySelector('.reconnect-suggestion-name').textContent) .toBe('main-checkpoint'); }); it('reconnects the checkpoint via its own endpoint when a suggestion is clicked', async () => { const recipeModal = await createRecipeModal(); const requests = mockCheckpointSuggestionsFetch({ success: true, suggestions: [ { file_name: 'main-checkpoint.safetensors', target_name: 'main-checkpoint', match_reason: 'same_version', score: 0.95, }, ], }); await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); document.querySelector('.checkpoint-reconnect').click(); const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); await vi.waitFor(() => { expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); }); container.querySelector('.reconnect-suggestion').click(); await vi.waitFor(() => { expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/reconnect')).toBe(true); }); const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/reconnect'); expect(reconnectRequest.options.method).toBe('POST'); expect(JSON.parse(reconnectRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', target_name: 'main-checkpoint', }); await vi.waitFor(() => { expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.checkpointReconnectedSuccessfully', {}, 'success' ); }); expect(recipeModal.currentRecipe.checkpoint.inLibrary).toBe(true); }); it('warns when the checkpoint reconnect crossed base-model families', async () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => { if (String(url).includes('/checkpoint/reconnect-suggestions')) { return { ok: true, json: async () => ({ success: true, suggestions: [] }) }; } if (String(url).includes('/recipe/checkpoint/reconnect')) { return { ok: true, json: async () => ({ success: true, updated_checkpoint: { name: 'main', inLibrary: true }, base_model_mismatch: { recipe_base_model: 'Illustrious', checkpoint_base_model: 'Pony' }, }), }; } return { ok: true, json: async () => ({}) }; }); await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); document.querySelector('.checkpoint-reconnect').click(); const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); const input = container.querySelector('.reconnect-input'); input.value = 'main'; container.querySelector('.reconnect-confirm-btn').click(); await vi.waitFor(() => { expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.reconnectCheckpointBaseModelMismatch', { recipe: 'Illustrious', checkpoint: 'Pony' }, 'warning' ); }); }); it('offers undo for a reconnected checkpoint and restores via the API', async () => { const recipeModal = await createRecipeModal(); const isolatedRecipe = recipeWithCheckpoint(brokenCheckpoint); isolatedRecipe.checkpoint = { name: 'main-checkpoint', file_name: 'main', inLibrary: true, reconnectSnapshot: { name: 'gone-checkpoint', file_name: 'gone', isDeleted: true }, }; fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); if (String(url).includes('/recipe/checkpoint/restore')) { return { ok: true, json: async () => ({ success: true, updated_checkpoint: { name: 'gone', inLibrary: false, isDeleted: true }, }), }; } return { ok: true, json: async () => ({}) }; }); recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); const item = document.querySelector('.checkpoint-item'); const undoButton = item.querySelector('.checkpoint-undo-reconnect'); expect(undoButton).not.toBeNull(); undoButton.click(); await vi.waitFor(() => { expect(showToastMock).toHaveBeenCalledWith('toast.recipes.checkpointRestored', {}, 'success'); }); const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/restore'); expect(restoreRequest.options.method).toBe('POST'); expect(JSON.parse(restoreRequest.options.body)).toEqual({ recipe_id: 'recipe-resources', }); }); it('marks the checkpoint hash invalid only when the failure is unresolvable', async () => { const recipeModal = await createRecipeModal(); const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js'); const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); return { ok: true, json: async () => ({ success: true }) }; }); // Explicit "model removed" signal: the entry becomes a rematch/ // reconnect candidate (same rule as the LoRA resolve "not found"). // Use an isolated copy so the hashInvalid mutation does not leak into // the shared recipeWithResources fixture used by later tests. const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); downloadVersionWithDefaultsMock.mockResolvedValue(false); downloadManager._lastDownloadError = 'Model not found'; recipeModal.showRecipeDetails(isolatedRecipe); await flushWiring(); document.querySelector('.checkpoint-download').click(); await vi.waitFor(() => { expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(true); }); const markRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid'); expect(markRequest.options.method).toBe('POST'); expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources' }); expect(recipeModal.currentRecipe.checkpoint.hashInvalid).toBe(true); }); it('does not mark the checkpoint hash invalid on transient download failures', async () => { const recipeModal = await createRecipeModal(); const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js'); const requests = []; global.fetch = vi.fn(async (url, options) => { requests.push({ url: String(url), options }); return { ok: true, json: async () => ({ success: true }) }; }); // Transport/API exceptions must NOT enroll the entry in the // remediation flow — transient failures are not evidence the model is // unrecoverable (mirrors the LoRA path). downloadVersionWithDefaultsMock.mockRejectedValue(new Error('Network timeout')); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); document.querySelector('.checkpoint-download').click(); await new Promise(resolve => setTimeout(resolve, 100)); expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false); // Business failure without an unresolvable signal also stays untouched. downloadVersionWithDefaultsMock.mockResolvedValue(false); downloadManager._lastDownloadError = 'Connection refused'; await recipeModal.downloadCheckpoint(recipeModal.currentRecipe.checkpoint); expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false); }); }); });