diff --git a/locales/de.json b/locales/de.json index 59dd60a1..11fcab53 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1581,6 +1581,21 @@ "downloadCsv": "CSV herunterladen", "columnModelName": "Modellname", "columnError": "Fehler" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/en.json b/locales/en.json index 123cd8b0..f4608e55 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1581,6 +1581,21 @@ "downloadCsv": "Download CSV", "columnModelName": "Model Name", "columnError": "Error" + }, + "downloadBatchSummary": { + "title": "Batch Download Summary", + "statSuccess": "Success", + "statFailed": "Failed", + "statTotal": "Total", + "successMessage": "All {count} models downloaded successfully", + "completedWithErrors": "Completed with errors", + "failed": "Download failed", + "failedItems": "Failed Items ({count})", + "columnName": "Model Name", + "columnError": "Error", + "close": "Close", + "copyReport": "Copy Report", + "retryFailed": "Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/es.json b/locales/es.json index 2130e1ea..a69682bc 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1581,6 +1581,21 @@ "downloadCsv": "Descargar CSV", "columnModelName": "Nombre del modelo", "columnError": "Error" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/fr.json b/locales/fr.json index cae78f7b..783d99bf 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1581,6 +1581,21 @@ "downloadCsv": "Télécharger CSV", "columnModelName": "Nom du modèle", "columnError": "Erreur" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/he.json b/locales/he.json index dc8f978d..1f1e8698 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1581,6 +1581,21 @@ "downloadCsv": "הורד CSV", "columnModelName": "שם המודל", "columnError": "שגיאה" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/ja.json b/locales/ja.json index 5dc90cad..d452eba8 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1581,6 +1581,21 @@ "downloadCsv": "CSVをダウンロード", "columnModelName": "モデル名", "columnError": "エラー" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/ko.json b/locales/ko.json index 1a931b85..7ef3a8c8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1581,6 +1581,21 @@ "downloadCsv": "CSV 다운로드", "columnModelName": "모델 이름", "columnError": "오류" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/ru.json b/locales/ru.json index 7f8df383..2be5e0e4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1581,6 +1581,21 @@ "downloadCsv": "Скачать CSV", "columnModelName": "Имя модели", "columnError": "Ошибка" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3bda05cb..cac873e1 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1581,6 +1581,21 @@ "downloadCsv": "下载 CSV", "columnModelName": "模型名称", "columnError": "错误" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 3ff4652b..b4d42cea 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1581,6 +1581,21 @@ "downloadCsv": "下載 CSV", "columnModelName": "模型名稱", "columnError": "錯誤" + }, + "downloadBatchSummary": { + "title": "[TODO: Translate] Batch Download Summary", + "statSuccess": "[TODO: Translate] Success", + "statFailed": "[TODO: Translate] Failed", + "statTotal": "[TODO: Translate] Total", + "successMessage": "[TODO: Translate] All {count} models downloaded successfully", + "completedWithErrors": "[TODO: Translate] Completed with errors", + "failed": "[TODO: Translate] Download failed", + "failedItems": "[TODO: Translate] Failed Items ({count})", + "columnName": "[TODO: Translate] Model Name", + "columnError": "[TODO: Translate] Error", + "close": "[TODO: Translate] Close", + "copyReport": "[TODO: Translate] Copy Report", + "retryFailed": "[TODO: Translate] Retry Failed ({count})" } }, "modelTags": { diff --git a/static/css/components/download-batch-summary.css b/static/css/components/download-batch-summary.css new file mode 100644 index 00000000..bff608e9 --- /dev/null +++ b/static/css/components/download-batch-summary.css @@ -0,0 +1,67 @@ +/* Batch Download Summary Modal — component styles only. + Stat cards and failure table styles are shared with the metadata refresh + result modal (metadata-refresh-result.css) and are not redefined here. */ + +.download-batch-summary-modal { + max-width: 700px; +} + +.summary-header { + display: flex; + align-items: center; + gap: var(--space-2); + margin: var(--space-2) 0; +} + +.summary-header i { + font-size: 1.4em; +} + +.summary-header.success i { + color: var(--color-success); +} + +.summary-header.warning i { + color: var(--color-warning); +} + +.summary-header.error i { + color: var(--color-error); +} + +.summary-title { + font-weight: var(--weight-semibold); + color: var(--lora-text); +} + +.summary-hint { + margin-left: auto; + font-size: var(--text-xs); + color: var(--text-secondary); +} + +.btn-retry { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: var(--lora-accent, #4f46e5); + color: #fff; + border: none; + border-radius: var(--border-radius-sm); + padding: var(--space-2) var(--space-3); + cursor: pointer; + font-weight: var(--weight-semibold); +} + +.btn-retry:hover { + background: var(--lora-accent-hover, #4338ca); +} + +.failure-link { + color: var(--lora-accent, #4f46e5); + text-decoration: none; +} + +.failure-link:hover { + text-decoration: underline; +} diff --git a/static/css/style.css b/static/css/style.css index b8460bf1..cb098abf 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -41,6 +41,7 @@ @import 'components/sidebar.css'; /* Add sidebar component */ @import 'components/media-viewer.css'; @import 'components/metadata-refresh-result.css'; +@import 'components/download-batch-summary.css'; .initialization-notice { display: flex; diff --git a/static/js/components/DownloadBatchSummaryModal.js b/static/js/components/DownloadBatchSummaryModal.js new file mode 100644 index 00000000..712b7457 --- /dev/null +++ b/static/js/components/DownloadBatchSummaryModal.js @@ -0,0 +1,340 @@ +import { translate } from '../utils/i18nHelpers.js'; +import { showToast, openHuggingFace } from '../utils/uiHelpers.js'; + +/** + * Escape HTML entities in a string to prevent injection when interpolating into innerHTML. + * Safe for both text content and attribute values (quotes are escaped too). + * @param {string} str - The string to escape + * @returns {string} - The escaped string + */ +function _escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * Resolve the display name of a failed download entry. + * Prefers the resolved name carried on the entry, then known item fields, + * then derives a name from the item URL as a last resort. + * @param {Object} entry - The failed entry ({ item, error, name? }) + * @returns {string} - The best available display name + */ +function _resolveItemName(entry) { + if (entry?.name) { + return entry.name; + } + const item = entry?.item ?? entry; + const direct = item?.displayName || item?.name || item?.file_name || item?.filename || item?.selectedVersion?.name; + if (direct) { + return direct; + } + if (item?.url) { + try { + const segments = new URL(item.url).pathname.split('/').filter(Boolean); + if (segments.length > 0) { + return decodeURIComponent(segments[segments.length - 1]); + } + } catch (e) { + // Unparseable URL — fall through to 'Unknown' + } + } + return 'Unknown'; +} + +/** + * Resolve the URL to open for a failed item — always the original item URL. + * @param {Object} item - The failed item payload + * @returns {string|null} - A URL string, or null when nothing is available + */ +function _resolveItemUrl(item) { + return item?.url || null; +} + +/** + * Format a raw failure error into a concise human-readable message. + * Unwraps JSON envelopes and extracts HTTP status/body details when present. + * @param {*} error - The raw error (usually a string) + * @returns {string} - The formatted error message + */ +function _formatError(error) { + if (!error) { + return 'Unknown error'; + } + let base = typeof error === 'string' ? error : String(error); + + // Unwrap JSON envelope: { "success": false, "error": "...", ... } + try { + const parsed = JSON.parse(base); + if (parsed && typeof parsed.error === 'string' && parsed.error) { + base = parsed.error; + } + } catch (e) { + // Not a JSON envelope — keep the raw string + } + + // Extract HTTP status and JSON body details, e.g. "status=403 body={...}" + let result = base; + const statusMatch = base.match(/status=(\d{3})/); + const bodyMatch = base.match(/body=(\{.*\})/s); + if (bodyMatch) { + try { + const body = JSON.parse(bodyMatch[1]); + const detail = (typeof body?.message === 'string' && body.message) + || (typeof body?.error === 'string' && body.error) + || null; + if (detail) { + const status = statusMatch ? statusMatch[1] : null; + result = `${status ? `HTTP ${status} — ` : ''}${detail}`; + } + } catch (e) { + // Body is not valid JSON — keep the base string + } + } + + // Truncate overly long messages + if (result.length > 220) { + result = result.slice(0, 220) + '…'; + } + return result; +} + +/** + * Build a plain-text report of the batch download results. + * @param {number} total - Total number of models attempted + * @param {number} completed - Number of models successfully downloaded + * @param {Array} failedItems - Array of failed items ({ item, error }) + * @returns {string} - The report text + */ +function _buildReportText(total, completed, failedItems) { + const lines = [ + '=== Batch Download Report ===', + `Date: ${new Date().toLocaleString()}`, + `Total: ${total}`, + `Successfully downloaded: ${completed}`, + `Failed: ${failedItems.length}`, + '', + ]; + if (failedItems.length > 0) { + lines.push('--- Failed Items ---'); + failedItems.forEach((entry, i) => { + const name = _resolveItemName(entry); + const error = _formatError(entry?.error); + lines.push(`${i + 1}. ${name} — ${error}`); + const itemUrl = _resolveItemUrl(entry?.item ?? entry); + if (itemUrl) { + lines.push(` URL: ${itemUrl}`); + } + }); + lines.push(''); + } + lines.push('===================='); + return lines.join('\n'); +} + +/** + * Handle a successful clipboard write: confirm via toast and briefly swap the + * trigger button to a "Copied!" state. + * @param {HTMLElement|null} btn - The button that triggered the copy action + */ +function _onCopyReportSuccess(btn) { + showToast('toast.api.copiedToClipboard', {}, 'success'); + if (btn) { + const origHTML = btn.innerHTML; + btn.innerHTML = ' Copied!'; + setTimeout(() => { btn.innerHTML = origHTML; }, 2000); + } +} + +/** + * Fallback for environments without the async Clipboard API (e.g. insecure + * contexts over LAN http where `navigator.clipboard` is undefined): copy via a + * hidden textarea and `document.execCommand('copy')`. + * @param {string} text - The report text to copy + */ +function _copyReportWithExecCommand(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showToast('toast.api.copiedToClipboard', {}, 'success'); +} + +/** + * Copy the batch download report to the clipboard. + * Uses the async Clipboard API when available, otherwise falls back to a hidden + * textarea + execCommand so the action still works in insecure contexts. + * @param {HTMLElement} btn - The button that triggered the copy action + * @param {number} total - Total number of models attempted + * @param {number} completed - Number of models successfully downloaded + * @param {Array} failedItems - Array of failed items + */ +function _copyReport(btn, total, completed, failedItems) { + const text = _buildReportText(total, completed, failedItems); + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(text) + .then(() => _onCopyReportSuccess(btn)) + .catch(() => _copyReportWithExecCommand(text)); + } else { + _copyReportWithExecCommand(text); + } +} + +/** + * Show the batch download summary modal after a batch download completes. + * Mirrors the Metadata Fetch Summary modal lifecycle: the modal element is + * appended directly to document.body and removed on close; it is not + * registered with ModalManager. + * @param {Object} options - Summary options + * @param {number} options.total - Total number of models attempted + * @param {number} options.completed - Number of models successfully downloaded + * @param {Array} options.failedItems - Array of failed items ({ item, error }) + * @param {Function} options.onRetry - Callback invoked with failedItems to retry the failed subset + */ +export function showDownloadBatchSummary({ total, completed, failedItems, onRetry }) { + const failures = failedItems || []; + const failedCount = failures.length; + + // 3-state summary header semantics (mirrors BatchImportManager results header) + let headerState; + let headerIcon; + let headerText; + if (completed === 0) { + headerState = 'error'; + headerIcon = 'fa-times-circle'; + headerText = translate('modals.downloadBatchSummary.failed', {}, 'Download failed'); + } else if (failedCount > 0) { + headerState = 'warning'; + headerIcon = 'fa-exclamation-circle'; + headerText = translate('modals.downloadBatchSummary.completedWithErrors', {}, 'Completed with errors'); + } else { + headerState = 'success'; + headerIcon = 'fa-check-circle'; + headerText = translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully'); + } + + // Build failure table rows + const failureRows = failures.map((entry, i) => { + const item = entry?.item ?? entry; + const name = _resolveItemName(entry); + const itemUrl = _resolveItemUrl(item); + const rawError = entry?.error ? String(entry.error) : ''; + const error = _formatError(entry?.error); + const nameCell = itemUrl + ? `${_escapeHtml(name)}` + : `${_escapeHtml(name)}`; + return ` + ${i + 1} + ${nameCell} + ${_escapeHtml(error)} + `; + }).join(''); + + const modalHtml = ` + + `; + + const existing = document.getElementById('downloadBatchSummaryModal'); + if (existing) existing.remove(); + + const container = document.createElement('div'); + container.innerHTML = modalHtml; + const modal = container.firstElementChild; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-action]'); + const action = actionEl?.dataset.action; + if (!action) return; + e.preventDefault(); + + switch (action) { + case 'close-modal': + modal.remove(); + break; + case 'retry-failed': + modal.remove(); + if (typeof onRetry === 'function') { + onRetry(failures); + } + break; + case 'copy-report': + _copyReport(actionEl, total, completed, failures); + break; + case 'open-model': { + // Keep the modal open; just open the item's original URL in a new tab + const entry = failures[Number(actionEl.dataset.index)]; + const item = entry?.item; + if (!item?.url) break; + openHuggingFace(item.url); + break; + } + } + }); +} diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 2ed83432..aeea0d11 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -8,6 +8,7 @@ import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { translate } from '../utils/i18nHelpers.js'; import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js'; import { formatFileSize } from '../utils/formatters.js'; +import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; export class DownloadManager { constructor() { @@ -1548,6 +1549,10 @@ export class DownloadManager { modalManager.closeModal('downloadModal'); + return this.executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }); + } + + async executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }) { const batchDownloadId = Date.now().toString(); const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); @@ -1558,6 +1563,7 @@ export class DownloadManager { let completedDownloads = 0; let failedDownloads = 0; let cancelled = false; + const failedItems = []; loadingManager.showCancelButton(async () => { if (cancelled) return; @@ -1658,6 +1664,7 @@ export class DownloadManager { if (!response.success) { failedDownloads++; + failedItems.push({ item, error: response.error || 'Unknown error', name }); } else { completedDownloads++; updateProgress(100, completedDownloads, ''); @@ -1666,6 +1673,7 @@ export class DownloadManager { if (!cancelled) { console.error(`Failed to download ${name}:`, err); failedDownloads++; + failedItems.push({ item, error: err?.message || 'Unknown error', name }); } } } @@ -1679,10 +1687,15 @@ export class DownloadManager { } else if (failedDownloads === 0) { showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); } else { - showToast('toast.loras.downloadPartialSuccess', { - completed: completedDownloads, + showDownloadBatchSummary({ total: downloadItems.length, - }, 'warning'); + completed: completedDownloads, + failedItems, + onRetry: (failed) => this.executeBatchDownload( + failed.map((f) => f.item), + { modelRoot, targetFolder, useDefaultPaths } + ), + }); } await resetAndReload(true); diff --git a/tests/frontend/components/downloadBatchSummary.test.js b/tests/frontend/components/downloadBatchSummary.test.js new file mode 100644 index 00000000..d79f49e0 --- /dev/null +++ b/tests/frontend/components/downloadBatchSummary.test.js @@ -0,0 +1,438 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + SUMMARY_MODULE, + I18N_HELPERS_MODULE, + UI_HELPERS_MODULE, +} = vi.hoisted(() => ({ + SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, +})); + +const showToastMock = vi.hoisted(() => vi.fn()); +const openHuggingFaceMock = vi.hoisted(() => vi.fn()); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_key, _params, fallback) => fallback ?? ''), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, + openHuggingFace: openHuggingFaceMock, +})); + +// A realistic failure payload from the backend: a JSON envelope whose `error` +// field embeds an HTTP status and a nested JSON body (Civitai Early Access). +const REAL_ERROR = '{"success": false, "error": "Failed to resolve authenticated Civitai redirect: status=403 body={\\"error\\":\\"Early Access\\",\\"deadline\\":\\"2026-08-12T08:18:36.063Z\\",\\"message\\":\\"This asset is in Early Access. You can use Buzz access it now!\\"}", "download_id": "1786065633067"}'; + +// The human-readable error the component should derive from REAL_ERROR. +const FORMATTED_REAL_ERROR = 'HTTP 403 — This asset is in Early Access. You can use Buzz access it now!'; + +describe('DownloadBatchSummaryModal', () => { + let showDownloadBatchSummary; + + beforeEach(async () => { + document.body.innerHTML = ''; + showToastMock.mockClear(); + openHuggingFaceMock.mockClear(); + ({ showDownloadBatchSummary } = await import(SUMMARY_MODULE)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete navigator.clipboard; + delete document.execCommand; + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('renders a warning summary with stat cards and a failure table on partial success', () => { + showDownloadBatchSummary({ + total: 3, + completed: 2, + failedItems: [ + { item: { displayName: 'LoraA' }, error: 'timeout' }, + { item: { name: 'LoraB' }, error: '404' }, + ], + onRetry: vi.fn(), + }); + + const modal = document.getElementById('downloadBatchSummaryModal'); + expect(modal).not.toBeNull(); + expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + + // Success / Failed / Total stat cards. + const statValues = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent); + expect(statValues).toEqual(['2', '2', '3']); + + const rows = modal.querySelectorAll('.failure-table tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[0].querySelector('.failure-name').textContent).toBe('LoraA'); + expect(rows[0].querySelector('.failure-error').textContent).toBe('timeout'); + expect(rows[1].querySelector('.failure-name').textContent).toBe('LoraB'); + expect(rows[1].querySelector('.failure-error').textContent).toBe('404'); + + expect(modal.querySelector('[data-action="retry-failed"]').textContent).toContain('Retry Failed (2)'); + expect(modal.querySelector('[data-action="copy-report"]')).not.toBeNull(); + }); + + it('renders an error header when every download failed', () => { + showDownloadBatchSummary({ + total: 2, + completed: 0, + failedItems: [ + { item: { displayName: 'LoraA' }, error: 'timeout' }, + { item: { displayName: 'LoraB' }, error: '404' }, + ], + onRetry: vi.fn(), + }); + + const modal = document.getElementById('downloadBatchSummaryModal'); + expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true); + expect(modal.querySelector('.summary-title').textContent).toBe('Download failed'); + }); + + it('renders a success summary without a failure table or retry button', () => { + showDownloadBatchSummary({ total: 2, completed: 2, failedItems: [], onRetry: vi.fn() }); + + const modal = document.getElementById('downloadBatchSummaryModal'); + expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true); + expect(modal.querySelector('.failure-table')).toBeNull(); + expect(modal.querySelector('[data-action="retry-failed"]')).toBeNull(); + expect(modal.querySelector('.refresh-success-message')).not.toBeNull(); + }); + + it('escapes HTML in failed item names and errors', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [ + { item: { name: '', url: 'https://example.com/xss-model' }, error: '' }, + ], + onRetry: vi.fn(), + }); + + const nameCell = document.querySelector('.failure-name'); + const errorCell = document.querySelector('.failure-error'); + + // The URL resolves, so the name renders inside the failure link; the + // escaped entities must render back to the literal payload as text... + expect(nameCell.querySelector('a.failure-link')).not.toBeNull(); + expect(nameCell.textContent).toContain(''); + expect(errorCell.textContent).toContain(''); + // ...and never as live DOM nodes. + expect(document.querySelector('.failure-table img')).toBeNull(); + expect(document.querySelector('.failure-table script')).toBeNull(); + expect(nameCell.innerHTML).toContain('<img'); + }); + + it('removes the modal and invokes onRetry with the original failed items', () => { + const onRetry = vi.fn(); + const failedItems = [{ item: { displayName: 'LoraA' }, error: 'timeout' }]; + showDownloadBatchSummary({ total: 3, completed: 2, failedItems, onRetry }); + + document.querySelector('[data-action="retry-failed"]').click(); + + expect(document.getElementById('downloadBatchSummaryModal')).toBeNull(); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith(failedItems); + // Same object references, not copies. + expect(onRetry.mock.calls[0][0][0]).toBe(failedItems[0]); + }); + + it('closes the modal via the close action without retrying', () => { + const onRetry = vi.fn(); + showDownloadBatchSummary({ + total: 2, + completed: 1, + failedItems: [{ item: { name: 'LoraA' }, error: 'timeout' }], + onRetry, + }); + + document.querySelector('.cancel-btn[data-action="close-modal"]').click(); + + expect(document.getElementById('downloadBatchSummaryModal')).toBeNull(); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('copies a plain-text batch report to the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + showDownloadBatchSummary({ + total: 3, + completed: 2, + failedItems: [ + { item: { displayName: 'LoraA', url: 'https://civitai.red/models/111/lora-a?modelVersionId=222' }, error: 'timeout' }, + { item: { name: 'LoraB', url: 'https://example.com/lora-b' }, error: '404' }, + ], + onRetry: vi.fn(), + }); + + document.querySelector('[data-action="copy-report"]').click(); + + // writeText is invoked synchronously by the click handler. + expect(writeText).toHaveBeenCalledTimes(1); + const text = writeText.mock.calls[0][0]; + expect(text).toContain('Batch Download Report'); + expect(text).toContain('Total: 3'); + expect(text).toContain('LoraA — timeout'); + expect(text).toContain('LoraB — 404'); + + // Each failed item with a URL gets an indented URL line right after it. + expect(text).toContain(' URL: https://civitai.red/models/111/lora-a?modelVersionId=222'); + expect(text).toContain(' URL: https://example.com/lora-b'); + // Exactly the two URLs from the failed items — nothing more, no undefined. + expect(text.match(/^\s+URL:/gm)).toHaveLength(2); + expect(text).not.toContain('URL: undefined'); + + // The toast fires after the mocked clipboard promise settles. + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1)); + expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'); + }); + + it('omits the URL line for failed items without a resolvable url', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + showDownloadBatchSummary({ + total: 2, + completed: 0, + failedItems: [ + { item: { name: 'WithUrl', url: 'https://example.com/with-url' }, error: 'boom' }, + { item: { name: 'NoUrl' }, error: 'boom' }, + ], + onRetry: vi.fn(), + }); + + document.querySelector('[data-action="copy-report"]').click(); + + const text = writeText.mock.calls[0][0]; + expect(text).toContain(' URL: https://example.com/with-url'); + // Only the one URL line exists — the URL-less item contributes none. + expect(text.match(/^\s+URL:/gm)).toHaveLength(1); + expect(text).not.toContain(' URL: undefined'); + expect(text).not.toContain(' URL: null'); + + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1)); + }); + + it('falls back to execCommand when navigator.clipboard is unavailable', async () => { + // afterEach deletes navigator.clipboard, but be explicit so this test is + // robust even if a previous test failed before its cleanup ran. + delete navigator.clipboard; + // jsdom does not implement document.execCommand, so install a mock for the + // fallback path (removed by the afterEach cleanup above). + const execCommandMock = vi.fn(() => true); + document.execCommand = execCommandMock; + + showDownloadBatchSummary({ + total: 3, + completed: 2, + failedItems: [ + { item: { displayName: 'LoraA' }, error: 'timeout' }, + { item: { name: 'LoraB' }, error: '404' }, + ], + onRetry: vi.fn(), + }); + + document.querySelector('[data-action="copy-report"]').click(); + + // Without the async Clipboard API the fallback must run synchronously. + expect(execCommandMock).toHaveBeenCalledWith('copy'); + + await Promise.resolve(); + await Promise.resolve(); + + expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'); + }); + + it('keeps only a single modal instance across repeated calls', () => { + showDownloadBatchSummary({ + total: 2, + completed: 1, + failedItems: [{ item: { name: 'A' }, error: 'e' }], + onRetry: vi.fn(), + }); + showDownloadBatchSummary({ total: 3, completed: 3, failedItems: [], onRetry: vi.fn() }); + + expect(document.querySelectorAll('#downloadBatchSummaryModal')).toHaveLength(1); + const modal = document.getElementById('downloadBatchSummaryModal'); + expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true); + }); + + it('resolves failure names from entry.name, item fields, URL paths, or Unknown', () => { + showDownloadBatchSummary({ + total: 4, + completed: 0, + failedItems: [ + { name: 'entryName', item: { displayName: 'ItemName' }, error: 'e1' }, + { item: { selectedVersion: { name: 'v1.0' } }, error: 'e2' }, + { item: { url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960' }, error: 'e3' }, + { item: {}, error: 'e4' }, + ], + onRetry: vi.fn(), + }); + + const names = Array.from(document.querySelectorAll('.failure-name')).map(el => el.textContent); + expect(names).toEqual(['entryName', 'v1.0', 'midjourney-artful-nsfw', 'Unknown']); + }); + + it('formats the real JSON failure payload into a concise HTTP error and truncates long ones', () => { + showDownloadBatchSummary({ + total: 2, + completed: 0, + failedItems: [ + { item: { name: 'EarlyAccess' }, error: REAL_ERROR }, + { item: { name: 'LongError' }, error: 'x'.repeat(300) }, + ], + onRetry: vi.fn(), + }); + + const errorCells = document.querySelectorAll('.failure-error'); + expect(errorCells[0].textContent).toBe(FORMATTED_REAL_ERROR); + expect(errorCells[1].textContent).toBe('x'.repeat(220) + '…'); + }); + + it('keeps the raw error string in the error cell title for debugging', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ item: { name: 'EarlyAccess' }, error: REAL_ERROR }], + onRetry: vi.fn(), + }); + + const errorCell = document.querySelector('.failure-error'); + expect(errorCell.getAttribute('title')).toBe(REAL_ERROR); + expect(errorCell.getAttribute('title')).not.toBe(FORMATTED_REAL_ERROR); + }); + + it('opens the original item url in a new tab when a failure link is clicked', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960', + modelId: '837884', + selectedVersion: { id: '3153960' }, + }, + error: 'rate limited', + }], + onRetry: vi.fn(), + }); + + document.querySelector('.failure-link').click(); + + expect(openHuggingFaceMock).toHaveBeenCalledTimes(1); + expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960'); + // The modal stays open so the user can keep inspecting the failures. + expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull(); + }); + + it('opens the item url directly when selectedVersion is absent', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + modelId: '837884', + modelVersionId: '3153960', + url: 'https://civitai.red/models/837884/midjourney-artful-nsfw', + }, + error: 'rate limited', + }], + onRetry: vi.fn(), + }); + + document.querySelector('.failure-link').click(); + + expect(openHuggingFaceMock).toHaveBeenCalledTimes(1); + expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw'); + expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull(); + }); + + it('opens the original huggingface url directly when a huggingface failure link is clicked', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + url: 'https://huggingface.co/user/repo', + source: 'huggingface', + repo: 'user/repo', + filename: 'model.safetensors', + revision: 'main', + }, + error: 'download failed', + }], + onRetry: vi.fn(), + }); + + document.querySelector('.failure-link').click(); + + expect(openHuggingFaceMock).toHaveBeenCalledTimes(1); + expect(openHuggingFaceMock).toHaveBeenCalledWith('https://huggingface.co/user/repo'); + expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull(); + }); + + it('opens an arbitrary URL via openHuggingFace for fallback items', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ item: { url: 'https://example.com/model' }, error: 'boom' }], + onRetry: vi.fn(), + }); + + document.querySelector('.failure-link').click(); + + expect(openHuggingFaceMock).toHaveBeenCalledTimes(1); + expect(openHuggingFaceMock).toHaveBeenCalledWith('https://example.com/model'); + }); + + it('renders the failure name as plain text when no URL can be resolved', () => { + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ item: { modelId: null }, error: 'boom' }], + onRetry: vi.fn(), + }); + + expect(document.querySelector('a.failure-link')).toBeNull(); + expect(document.querySelector('.failure-name').textContent).toBe('Unknown'); + + // Without a link there is nothing to open: clicking the cell is inert. + document.querySelector('.failure-name').click(); + expect(openHuggingFaceMock).not.toHaveBeenCalled(); + }); + + it('copies formatted errors (not raw JSON) into the report text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + name: 'EarlyAccess', + url: 'https://civitai.red/models/123/early-access?modelVersionId=456', + }, + error: REAL_ERROR, + }], + onRetry: vi.fn(), + }); + + document.querySelector('[data-action="copy-report"]').click(); + + expect(writeText).toHaveBeenCalledTimes(1); + const text = writeText.mock.calls[0][0]; + expect(text).toContain(FORMATTED_REAL_ERROR); + expect(text).toContain(' URL: https://civitai.red/models/123/early-access?modelVersionId=456'); + expect(text).not.toContain('download_id'); + expect(text).not.toContain('Failed to resolve authenticated Civitai redirect'); + + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/tests/frontend/managers/downloadManager.batchSummary.test.js b/tests/frontend/managers/downloadManager.batchSummary.test.js new file mode 100644 index 00000000..30cb057e --- /dev/null +++ b/tests/frontend/managers/downloadManager.batchSummary.test.js @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + DOWNLOAD_MANAGER_MODULE, + MODAL_MANAGER_MODULE, + UI_HELPERS_MODULE, + STATE_MODULE, + LOADING_MANAGER_MODULE, + API_FACTORY_MODULE, + STORAGE_HELPERS_MODULE, + FOLDER_TREE_MANAGER_MODULE, + I18N_HELPERS_MODULE, + SUMMARY_MODULE, + mockApiClient, + mockLoadingManager, + showToastMock, + showDownloadBatchSummaryMock, + resetAndReloadMock, +} = vi.hoisted(() => { + // Shared API client returned by the mocked getModelApiClient factory. + const mockApiClient = { + apiConfig: { + config: { + displayName: 'LoRA', + singularName: 'lora', + }, + }, + downloadModel: vi.fn(), + downloadHfModel: vi.fn(), + cancelDownload: vi.fn(), + }; + + // Shared loading manager served both via state.loadingManager and the + // LoadingManager constructor mock. + const mockLoadingManager = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + restoreProgressBar: vi.fn(), + showDownloadProgress: vi.fn(() => vi.fn()), + setStatus: vi.fn(), + showCancelButton: vi.fn(), + }; + + return { + DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname, + mockApiClient, + mockLoadingManager, + showToastMock: vi.fn(), + showDownloadBatchSummaryMock: vi.fn(), + resetAndReloadMock: vi.fn(), + }; +}); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: vi.fn(), + }, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + global: { + settings: {}, + }, + loadingManager: mockLoadingManager, + }, +})); + +vi.mock(LOADING_MANAGER_MODULE, () => ({ + LoadingManager: vi.fn(() => mockLoadingManager), +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(() => mockApiClient), + resetAndReload: resetAndReloadMock, +})); + +vi.mock(STORAGE_HELPERS_MODULE, () => ({ + getStorageItem: vi.fn((_key, defaultValue) => defaultValue), + setStorageItem: vi.fn(), +})); + +vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({ + FolderTreeManager: vi.fn(() => ({ + clearSelection: vi.fn(), + init: vi.fn(), + })), +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback ?? ''), +})); + +vi.mock(SUMMARY_MODULE, () => ({ + showDownloadBatchSummary: showDownloadBatchSummaryMock, +})); + +/** + * Fake WebSocket used by executeBatchDownload. Resolves `onopen` on the + * microtask queue right after construction (which happens after the real + * code has assigned `onopen`), so the open promise resolves deterministically + * without real timers. + */ +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(); + }); + } + + static get lastInstance() { + return FakeWebSocket.instances[FakeWebSocket.instances.length - 1]; + } +} + +describe('DownloadManager batch download summary flow', () => { + let DownloadManager; + let manager; + + const options = { modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true }; + + const makeItem = (modelId, versionId, name) => ({ + modelId, + displayName: name, + selectedVersion: { id: versionId, name, existsLocally: false }, + }); + + const item0 = makeItem('111', 'v1', 'Model A'); + const item1 = makeItem('222', 'v2', 'Model B'); + + beforeEach(async () => { + document.body.innerHTML = ''; + FakeWebSocket.instances = []; + + // Reset the shared mocks so mockResolvedValueOnce queues and call + // history never leak between tests. + mockApiClient.downloadModel.mockReset(); + mockApiClient.downloadHfModel.mockReset(); + mockApiClient.cancelDownload.mockReset(); + showToastMock.mockClear(); + showDownloadBatchSummaryMock.mockClear(); + resetAndReloadMock.mockClear(); + mockLoadingManager.hide.mockClear(); + mockLoadingManager.setStatus.mockClear(); + mockLoadingManager.showCancelButton.mockClear(); + mockLoadingManager.showDownloadProgress.mockClear(); + + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.resetModules(); + ({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE)); + manager = new DownloadManager(); + // The constructor leaves apiClient null; executeBatchDownload reads it + // directly, so point it at the shared mocked client. + manager.apiClient = mockApiClient; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows the success toast when every item downloads successfully', async () => { + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + + await manager.executeBatchDownload([item0, item1], options); + + expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2); + // Each item is downloaded with its own modelId + versionId. + expect(mockApiClient.downloadModel.mock.calls[0][0]).toBe('111'); + expect(mockApiClient.downloadModel.mock.calls[0][1]).toBe('v1'); + expect(mockApiClient.downloadModel.mock.calls[1][0]).toBe('222'); + expect(mockApiClient.downloadModel.mock.calls[1][1]).toBe('v2'); + + expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', { count: 2 }, 'success'); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('shows a partial-failure summary when some items fail', async () => { + // The failing item has no displayName/filename, so the resolved entry + // name falls back to the selected version name. + const unnamedItem = { modelId: '333', selectedVersion: { id: 'v3', name: 'V3', existsLocally: false } }; + mockApiClient.downloadModel + .mockResolvedValueOnce({ success: false, error: 'rate limited' }) + .mockResolvedValueOnce({ success: true }); + + await manager.executeBatchDownload([unnamedItem, item1], options); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(2); + expect(summary.completed).toBe(1); + expect(summary.failedItems).toHaveLength(1); + expect(summary.failedItems[0].item).toBe(unnamedItem); + expect(summary.failedItems[0].error).toBe('rate limited'); + // The resolved display name is carried on the failed entry. + expect(summary.failedItems[0].name).toBe('V3'); + expect(summary.onRetry).toEqual(expect.any(Function)); + + // No success toast and no downloadPartialSuccess toast for this path. + expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), 'success'); + expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadPartialSuccess', expect.anything(), expect.anything()); + }); + + it('shows an all-failed summary when every item fails', async () => { + mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'x' }); + + await manager.executeBatchDownload([item0, item1], options); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(2); + expect(summary.completed).toBe(0); + expect(summary.failedItems).toHaveLength(2); + expect(summary.failedItems[0].item).toBe(item0); + expect(summary.failedItems[1].item).toBe(item1); + expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), expect.anything()); + }); + + it('records the error message when downloadModel rejects', async () => { + // The item carries a filename but no displayName, so the resolved entry + // name comes from the filename. + const filenameItem = { modelId: '444', filename: 'model.safetensors', selectedVersion: { id: 'v4' } }; + mockApiClient.downloadModel.mockRejectedValue(new Error('network down')); + + await manager.executeBatchDownload([filenameItem], options); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(1); + expect(summary.completed).toBe(0); + expect(summary.failedItems).toHaveLength(1); + expect(summary.failedItems[0].item).toBe(filenameItem); + expect(summary.failedItems[0].error).toBe('network down'); + expect(summary.failedItems[0].name).toBe('model.safetensors'); + }); + + it('retries the failed subset through onRetry with unwrapped items', async () => { + mockApiClient.downloadModel + .mockResolvedValueOnce({ success: false, error: 'rate limited' }) + .mockResolvedValueOnce({ success: true }); + + await manager.executeBatchDownload([item0, item1], options); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.failedItems).toHaveLength(1); + + // Retry the exact failed subset returned by the summary. The onRetry + // callback unwraps the { item, error } entries back into raw model items + // before re-running executeBatchDownload. Make the retried item fail + // again so a second summary is produced. + mockApiClient.downloadModel.mockResolvedValueOnce({ success: false, error: 'still rate limited' }); + await summary.onRetry(summary.failedItems); + + // downloadModel is called a third time — only for the failed item (item0), + // NOT for the item that already succeeded (item1). + expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(3); + const retryCall = mockApiClient.downloadModel.mock.calls[2]; + expect(retryCall[0]).toBe(item0.modelId); + expect(retryCall[1]).toBe(item0.selectedVersion.id); + + // A fresh summary is produced for the retry run (call count 1 -> 2). + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(2); + const retrySummary = showDownloadBatchSummaryMock.mock.calls[1][0]; + expect(retrySummary.total).toBe(1); + expect(retrySummary.completed).toBe(0); + expect(retrySummary.failedItems).toHaveLength(1); + expect(retrySummary.failedItems[0].item).toBe(item0); + expect(retrySummary.failedItems[0].error).toBe('still rate limited'); + }); + + it('stops the batch without showing a summary when cancelled before downloads start', async () => { + const downloadPromise = manager.executeBatchDownload([item0, item1], options); + + // showCancelButton captured the cancel callback synchronously. Invoking it + // sets `cancelled = true` before the download loop runs (the loop only + // starts after the WebSocket open promise resolves on the microtask queue). + const cancelCallback = mockLoadingManager.showCancelButton.mock.calls[0][0]; + const cancelPromise = cancelCallback(); + + await Promise.all([downloadPromise, cancelPromise]); + + expect(mockApiClient.downloadModel).not.toHaveBeenCalled(); + expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.downloads.downloadStopped', + expect.anything(), + 'info', + expect.stringContaining('Download cancelled') + ); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); +});