diff --git a/e2e/transfer-performance.spec.js b/e2e/transfer-performance.spec.js index a5d13b8..124c8e8 100644 --- a/e2e/transfer-performance.spec.js +++ b/e2e/transfer-performance.spec.js @@ -550,13 +550,23 @@ test('pairs two devices and establishes a real transfer performance baseline', a expect(senderTransfers).toHaveLength(files.length); expect(receiverTransfers).toHaveLength(files.length); - await expect.poll( - () => devices.receiver.downloadCount, - { - message: 'Receiver completion did not produce exactly one download per file.', - timeout: 15_000 - } - ).toBe(receiverDownloadsInitial + files.length); + await devices.receiver.page.waitForTimeout(250); + expect(devices.receiver.downloadCount).toBe(receiverDownloadsInitial); + + const readyDownloads = devices.receiver.page.locator( + '#received-files-list .received-file-item.is-ready' + ); + await expect(readyDownloads).toHaveCount(files.length); + await expect(readyDownloads.locator('.received-file-status')) + .toHaveText(Array(files.length).fill('Ready to download')); + + let secondPendingHref = null; + if (files.length === 2) { + const pendingHrefs = await readyDownloads.locator('.received-file-download') + .evaluateAll((links) => links.map((link) => link.href)); + expect(new Set(pendingHrefs).size).toBe(2); + secondPendingHref = pendingHrefs[1]; + } for (let index = 0; index < files.length; index += 1) { const expectedSize = files[index].buffer.length; @@ -605,6 +615,30 @@ test('pairs two devices and establishes a real transfer performance baseline', a expect(receiverSnapshot.sequence).toBeGreaterThan(receiverTransfers[index - 1].sequence); } + const receivedRow = devices.receiver.page.locator( + '#received-files-list .received-file-item.is-ready', + { hasText: files[index].name } + ); + await expect(receivedRow).toHaveCount(1); + const downloadPromise = devices.receiver.page.waitForEvent('download'); + await receivedRow.locator('.received-file-download').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(files[index].name); + await expect(devices.receiver.page.locator( + '#received-files-list .received-file-item.is-downloaded', + { hasText: files[index].name } + )).toContainText('Downloaded'); + expect(devices.receiver.downloadCount).toBe(receiverDownloadsInitial + index + 1); + + if (files.length === 2 && index === 0) { + const secondPendingDownload = devices.receiver.page.locator( + '#received-files-list .received-file-item.is-ready', + { hasText: files[1].name } + ).locator('.received-file-download'); + await expect(secondPendingDownload).toBeVisible(); + await expect(secondPendingDownload).toHaveAttribute('href', secondPendingHref); + } + reportIndex += 1; await savePerformanceReport( testInfo, diff --git a/public/app.html b/public/app.html index d04a04d..448fdd4 100644 --- a/public/app.html +++ b/public/app.html @@ -330,15 +330,27 @@

Transfer Completed!

0 MB
- - Download File -
+ + @@ -447,7 +459,11 @@

Transfer Completed!

btn_send_text: "Send Text", history_title: "Session History", transfer_completed: "Transfer Completed!", - btn_download: "Download File", + received_files_title: "Received files", + ready_to_download: "Ready to download", + btn_download: "Download", + downloaded: "Downloaded", + download_pending_files: "Download pending files", btn_transfer_another: "Transfer Another File", queue_title: "Review files", queue_waiting: "files ready", @@ -595,7 +611,11 @@

Transfer Completed!

btn_send_text: "Enviar Texto", history_title: "Historial de la Sesión", transfer_completed: "¡Transferencia completada!", - btn_download: "Descargar archivo", + received_files_title: "Archivos recibidos", + ready_to_download: "Listo para descargar", + btn_download: "Descargar", + downloaded: "Descargado", + download_pending_files: "Descargar archivos pendientes", btn_transfer_another: "Transferir otro archivo", queue_title: "Revisa los archivos", queue_waiting: "archivos listos", diff --git a/public/js/app.js b/public/js/app.js index 5105397..159e2f2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -150,38 +150,95 @@ function createPairingPrivacyFacade(helper) { }); } -function createReceivedBlobUrlLifecycle(downloadLink, urlApi) { - let activeUrl = null; - - const clear = () => { - if (!activeUrl) return false; - const urlToRevoke = activeUrl; - activeUrl = null; - downloadLink.href = '#'; - downloadLink.removeAttribute('download'); +function createReceivedDownloadsManager( + urlApi, + scheduleRelease = globalThis.setTimeout, + releaseDelayMs = 1000 +) { + const downloads = new Map(); + let nextId = 1; + + const createSnapshot = (item) => Object.freeze({ + id: item.id, + fileName: item.fileName, + size: item.size, + status: item.status, + url: item.url + }); + + const revokeUrl = (id) => { + const item = downloads.get(id); + if (!item || !item.url) return false; + const urlToRevoke = item.url; + item.url = null; urlApi.revokeObjectURL(urlToRevoke); return true; }; + const release = (id) => { + const revoked = revokeUrl(id); + downloads.delete(id); + return revoked; + }; + const install = (blob, fileName) => { - clear(); - const nextUrl = urlApi.createObjectURL(blob); - activeUrl = nextUrl; - downloadLink.href = nextUrl; - downloadLink.setAttribute('download', fileName); - return nextUrl; + const item = { + id: nextId, + fileName: String(fileName || ''), + size: Number(blob?.size || 0), + status: 'ready', + url: urlApi.createObjectURL(blob), + releaseScheduled: false + }; + nextId += 1; + downloads.set(item.id, item); + return createSnapshot(item); + }; + + const startDownload = (id) => { + const item = downloads.get(id); + if (!item || item.status !== 'ready' || !item.url) return false; + item.status = 'downloaded'; + if (!item.releaseScheduled) { + item.releaseScheduled = true; + scheduleRelease(() => revokeUrl(id), releaseDelayMs); + } + return true; + }; + + const clearAll = () => { + let released = 0; + for (const item of downloads.values()) { + if (item.url && revokeUrl(item.id)) released += 1; + } + downloads.clear(); + return released; }; + const getItems = () => Object.freeze( + Array.from(downloads.values(), createSnapshot) + ); + + const getPendingItems = () => Object.freeze( + Array.from(downloads.values()) + .filter((item) => item.status === 'ready' && Boolean(item.url)) + .map(createSnapshot) + ); + return Object.freeze({ - clear, + clearAll, + getItems, + getPendingItems, install, - getActiveUrl: () => activeUrl + release, + startDownload }); } -function clearReceivedBlobUrlOnPageHide(event, receivedBlobUrls) { +function clearReceivedDownloadsOnPageHide(event, receivedDownloads) { if (event.persisted === true) return false; - return receivedBlobUrls.clear(); + receivedDownloads.clearAll(); + return true; } function isAutomaticReconnectAllowed(sessionState) { @@ -317,9 +374,12 @@ document.addEventListener('DOMContentLoaded', () => { const completedCard = document.getElementById('completed-card'); const completedFileName = document.getElementById('completed-file-name'); const completedFileSize = document.getElementById('completed-file-size'); - const btnDownload = document.getElementById('btn-download'); const btnResetTransfer = document.getElementById('btn-reset-transfer'); - const receivedBlobUrls = createReceivedBlobUrlLifecycle(btnDownload, URL); + const receivedFilesCard = document.getElementById('received-files-card'); + const receivedFilesSummary = document.getElementById('received-files-summary'); + const receivedFilesList = document.getElementById('received-files-list'); + const btnReceiveAnother = document.getElementById('btn-receive-another'); + const receivedDownloads = createReceivedDownloadsManager(URL); // History Elements const historyContainer = document.getElementById('history-container'); @@ -495,6 +555,87 @@ document.addEventListener('DOMContentLoaded', () => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } + function renderReceivedFiles() { + const items = receivedDownloads.getItems(); + const pendingItems = receivedDownloads.getPendingItems(); + receivedFilesCard.classList.toggle('hidden', items.length === 0); + receivedFilesSummary.textContent = translate( + pendingItems.length > 0 ? 'download_pending_files' : 'downloaded' + ); + receivedFilesList.replaceChildren(); + + items.forEach((item) => { + const row = document.createElement('div'); + row.className = `received-file-item is-${item.status}`; + row.setAttribute('role', 'listitem'); + + const info = document.createElement('div'); + info.className = 'received-file-info'; + + const name = document.createElement('span'); + name.className = 'received-file-name'; + name.textContent = item.fileName; + name.title = item.fileName; + + const meta = document.createElement('div'); + meta.className = 'received-file-meta'; + + const size = document.createElement('span'); + size.textContent = formatBytes(item.size); + + const separator = document.createElement('span'); + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = '·'; + + const status = document.createElement('span'); + status.className = 'received-file-status'; + status.textContent = translate(item.status === 'ready' ? 'ready_to_download' : 'downloaded'); + + meta.appendChild(size); + meta.appendChild(separator); + meta.appendChild(status); + info.appendChild(name); + info.appendChild(meta); + row.appendChild(info); + + if (item.status === 'ready' && item.url) { + const downloadLink = document.createElement('a'); + downloadLink.className = 'btn btn-primary received-file-download'; + downloadLink.href = item.url; + downloadLink.download = item.fileName; + downloadLink.textContent = translate('btn_download'); + downloadLink.setAttribute('aria-label', `${translate('btn_download')}: ${item.fileName}`); + downloadLink.addEventListener('click', (event) => { + const fileCountBucket = receivedDownloads.getPendingItems().length === 1 + ? 'one' + : 'multiple'; + if (!receivedDownloads.startDownload(item.id)) { + event.preventDefault(); + return; + } + + downloadLink.setAttribute('aria-disabled', 'true'); + status.textContent = translate('downloaded'); + row.classList.remove('is-ready'); + row.classList.add('is-downloaded'); + trackFlowAnalytics('receiver_download_clicked', { + file_count_bucket: fileCountBucket + }); + setTimeout(renderReceivedFiles, 0); + }); + row.appendChild(downloadLink); + } + + receivedFilesList.appendChild(row); + }); + } + + function clearReceivedDownloads() { + const released = receivedDownloads.clearAll(); + renderReceivedFiles(); + return released; + } + function switchView(viewName) { if (viewName === 'setup') { setupView.classList.remove('hidden'); @@ -1145,6 +1286,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!recovered) { sessionRecoveryState = markManualActionDelivered(sessionRecoveryState); webrtcManager.prepareForNewPairingSignals(); + clearReceivedDownloads(); connectionEstablishedTracked = false; trackFlowAnalytics('room_joined', { role, @@ -1231,6 +1373,7 @@ document.addEventListener('DOMContentLoaded', () => { reconnectAttempts = clearedReconnect.attempts; sessionRecoveryState = 'manual-reconnect'; p2pConnected = false; + clearReceivedDownloads(); switchView('setup'); }; @@ -1344,7 +1487,6 @@ document.addEventListener('DOMContentLoaded', () => { } webrtcManager.onFileTransferStart = (fileName, totalBytes, isSending, options = {}) => { - receivedBlobUrls.clear(); activeTransferMode = options.writeMode || 'send'; transferIsActive = true; acquireTransferWakeLock(); @@ -1426,42 +1568,28 @@ document.addEventListener('DOMContentLoaded', () => { updateOnboarding(4, { complete: true }); progressCard.classList.add('hidden'); networkDiagnostics.classList.add('hidden'); - if (sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering') { - completedCard.classList.add('hidden'); - } else { - completedCard.classList.remove('hidden'); - } - completedFileName.textContent = fileName; if (options.savedToDisk) { - receivedBlobUrls.clear(); + completedCard.classList.toggle( + 'hidden', + sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering' + ); completedFileSize.textContent = translate('saved_to_disk'); - btnDownload.classList.add('hidden'); appendHistoryItem(fileName, translate('saved_to_disk'), 'received'); } else if (fileBlob) { - // Received mode const sizeStr = formatBytes(fileBlob.size); - completedFileSize.textContent = sizeStr; - - receivedBlobUrls.install(fileBlob, fileName); - btnDownload.classList.remove('hidden'); - + completedCard.classList.add('hidden'); + receivedDownloads.install(fileBlob, fileName); + renderReceivedFiles(); appendHistoryItem(fileName, sizeStr, 'received'); - - // Premium UX: Auto-trigger download - try { - btnDownload.click(); - } catch (err) { - console.error('Auto download failed, user must click button manually', err); - } } else { - // Sent mode - receivedBlobUrls.clear(); const sizeStr = formatBytes(currentFileTransferSize); + completedCard.classList.toggle( + 'hidden', + sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering' + ); completedFileSize.textContent = translate('sent_success'); - btnDownload.classList.add('hidden'); - appendHistoryItem(fileName, sizeStr, 'sent'); } }; @@ -1493,7 +1621,6 @@ document.addEventListener('DOMContentLoaded', () => { }; webrtcManager.onTransferError = (details = {}) => { - receivedBlobUrls.clear(); activeTransferMode = 'idle'; transferIsActive = false; releaseTransferWakeLock(); @@ -1514,7 +1641,6 @@ document.addEventListener('DOMContentLoaded', () => { }; webrtcManager.onFileTransferCancelled = (fileName, isLocal) => { - receivedBlobUrls.clear(); activeTransferMode = 'idle'; transferIsActive = false; releaseTransferWakeLock(); @@ -1598,7 +1724,12 @@ document.addEventListener('DOMContentLoaded', () => { completedCard.classList.add('hidden'); dropZone.classList.remove('hidden'); updateOnboarding(3); - receivedBlobUrls.clear(); + }); + + btnReceiveAnother.addEventListener('click', () => { + completedCard.classList.add('hidden'); + dropZone.classList.remove('hidden'); + updateOnboarding(3); }); btnSendText.addEventListener('click', () => { @@ -1679,7 +1810,7 @@ document.addEventListener('DOMContentLoaded', () => { // --- APP RESET & CLEANUP --- function resetApp({ preserveQueue = false } = {}) { - receivedBlobUrls.clear(); + clearReceivedDownloads(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -1773,13 +1904,16 @@ document.addEventListener('DOMContentLoaded', () => { }); window.addEventListener('airdows:language-change', () => { - renderQueue(); - updateConnectedStatus(); - updateOnboarding(onboardingStep, { complete: onboardingComplete }); + queueMicrotask(() => { + renderQueue(); + renderReceivedFiles(); + updateConnectedStatus(); + updateOnboarding(onboardingStep, { complete: onboardingComplete }); + }); }); window.addEventListener('pagehide', (event) => { - clearReceivedBlobUrlOnPageHide(event, receivedBlobUrls); + clearReceivedDownloadsOnPageHide(event, receivedDownloads); }); document.addEventListener('visibilitychange', () => { diff --git a/public/js/pairing-link-privacy.js b/public/js/pairing-link-privacy.js index e219ce4..6dc213b 100644 --- a/public/js/pairing-link-privacy.js +++ b/public/js/pairing-link-privacy.js @@ -34,6 +34,7 @@ transfer_completed: ['direction', 'route', 'size_bucket', 'flow_version'], transfer_failed: ['direction', 'route', 'failure_type', 'flow_version'], transfer_cancelled: ['direction', 'initiated_by', 'flow_version'], + receiver_download_clicked: ['flow_version', 'file_count_bucket'], route_selected: ['route'] }); diff --git a/public/style.css b/public/style.css index 9fb92e4..1198613 100644 --- a/public/style.css +++ b/public/style.css @@ -1537,6 +1537,102 @@ body { font-size: 0.9rem; } +.received-files-card { + background: rgba(8, 17, 20, 0.74); + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 18px; + margin-top: 18px; + padding: 22px; +} + +.received-files-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.received-files-header h3 { + font-size: 1.05rem; + font-weight: 750; +} + +.received-files-header p { + color: var(--text-secondary); + font-size: 0.8rem; + margin-top: 4px; +} + +.received-files-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +.received-file-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-light); + border-radius: 14px; + padding: 13px 14px; +} + +.received-file-item.is-downloaded { + border-left: 3px solid var(--color-success); +} + +.received-file-info { + min-width: 0; +} + +.received-file-name { + color: var(--text-primary); + display: block; + font-size: 0.9rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.received-file-meta { + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + font-size: 0.76rem; + gap: 6px; + margin-top: 4px; +} + +.received-file-status { + color: #7dd3fc; + font-weight: 750; +} + +.received-file-item.is-downloaded .received-file-status { + color: #6ee7b7; +} + +.received-file-download { + min-height: 42px; + min-width: 116px; + padding: 9px 14px; +} + +.received-file-download[aria-disabled="true"] { + opacity: 0.65; + pointer-events: none; +} + +.received-files-card > .btn { + width: 100%; +} + /* Toast Notifications */ .toast { position: fixed; @@ -1765,6 +1861,18 @@ body { flex-direction: column; } + .received-files-card { + padding: 17px; + } + + .received-file-item { + grid-template-columns: minmax(0, 1fr); + } + + .received-file-download { + width: 100%; + } + .diagnostic-grid { grid-template-columns: 1fr; } diff --git a/test/pairing-link-privacy.test.js b/test/pairing-link-privacy.test.js index 9770ac2..7d371c9 100644 --- a/test/pairing-link-privacy.test.js +++ b/test/pairing-link-privacy.test.js @@ -216,6 +216,19 @@ test('analytics properties exclude pairing and content identifiers', () => { assert.deepEqual(properties, { role: 'receiver' }); assert.equal(JSON.stringify(properties).includes('1234'), false); + + const downloadProperties = sanitizeAnalyticsProperties('receiver_download_clicked', { + flow_version: 'first-transfer-v2', + file_count_bucket: 'multiple', + fileName: 'private.pdf', + size: 987654, + mime: 'application/pdf', + blobUrl: 'blob:private' + }); + assert.deepEqual(downloadProperties, { + flow_version: 'first-transfer-v2', + file_count_bucket: 'multiple' + }); }); test('every current analytics event preserves its safe properties', () => { @@ -253,6 +266,9 @@ test('every current analytics event preserves its safe properties', () => { }, transfer_cancelled: { direction: 'receive', initiated_by: 'remote', flow_version: 'first-transfer-v2' + }, + receiver_download_clicked: { + flow_version: 'first-transfer-v2', file_count_bucket: 'multiple' } }; diff --git a/test/received-blob-lifecycle.test.js b/test/received-blob-lifecycle.test.js index 3044d26..72aaea3 100644 --- a/test/received-blob-lifecycle.test.js +++ b/test/received-blob-lifecycle.test.js @@ -7,23 +7,27 @@ const path = require('node:path'); const vm = require('node:vm'); const appPath = path.join(__dirname, '..', 'public', 'js', 'app.js'); +const webrtcPath = path.join(__dirname, '..', 'public', 'js', 'webrtc-manager.js'); +const htmlPath = path.join(__dirname, '..', 'public', 'app.html'); const appSource = fs.readFileSync(appPath, 'utf8'); +const webrtcSource = fs.readFileSync(webrtcPath, 'utf8'); +const htmlSource = fs.readFileSync(htmlPath, 'utf8'); -function loadLifecycleFactory() { - const startAnchor = 'function createReceivedBlobUrlLifecycle'; +function loadManagerFactory() { + const startAnchor = 'function createReceivedDownloadsManager'; const endAnchor = '\nfunction isAutomaticReconnectAllowed'; const start = appSource.indexOf(startAnchor); const end = appSource.indexOf(endAnchor, start); assert.notEqual(start, -1, `Missing source anchor: ${startAnchor}`); assert.notEqual(end, -1, `Missing source anchor: ${endAnchor}`); - assert.ok(end > start, 'Received Blob lifecycle source anchors are out of order'); + assert.ok(end > start, 'Received download manager source anchors are out of order'); const context = {}; vm.createContext(context); vm.runInContext( `${appSource.slice(start, end)} -this.factory = createReceivedBlobUrlLifecycle; -this.clearOnPageHide = clearReceivedBlobUrlOnPageHide;`, +this.factory = createReceivedDownloadsManager; +this.clearOnPageHide = clearReceivedDownloadsOnPageHide;`, context ); return { @@ -32,21 +36,12 @@ this.clearOnPageHide = clearReceivedBlobUrlOnPageHide;`, }; } -const lifecycleHelpers = loadLifecycleFactory(); +const managerHelpers = loadManagerFactory(); function createHarness() { const revoked = []; const created = []; - const link = { - href: '#', - attributes: new Map(), - setAttribute(name, value) { - this.attributes.set(name, value); - }, - removeAttribute(name) { - this.attributes.delete(name); - } - }; + const scheduled = []; const urlApi = { createObjectURL(blob) { const url = `blob:received-${created.length + 1}`; @@ -57,127 +52,221 @@ function createHarness() { revoked.push(url); } }; - const lifecycle = lifecycleHelpers.factory(link, urlApi); - return { lifecycle, link, created, revoked }; + const manager = managerHelpers.factory(urlApi, (callback, delay) => { + scheduled.push({ callback, delay }); + }); + const runScheduled = () => { + while (scheduled.length) scheduled.shift().callback(); + }; + return { manager, created, revoked, scheduled, runScheduled }; } -test('two consecutive received files revoke the first URL before installing the second', () => { - const { lifecycle, link, created, revoked } = createHarness(); - const firstUrl = lifecycle.install({ name: 'first' }, 'first.bin'); - const secondUrl = lifecycle.install({ name: 'second' }, 'second.bin'); +test('receiving a file does not start an automatic download', () => { + const { manager, created, revoked, scheduled } = createHarness(); - assert.deepEqual(revoked, [firstUrl]); - assert.equal(created.length, 2); - assert.equal(lifecycle.getActiveUrl(), secondUrl); - assert.equal(link.href, secondUrl); - assert.equal(link.attributes.get('download'), 'second.bin'); + const item = manager.install({ size: 4 }, 'received.bin'); + + assert.equal(item.status, 'ready'); + assert.equal(created.length, 1); + assert.deepEqual(revoked, []); + assert.equal(scheduled.length, 0); + assert.doesNotMatch( + appSource, + /btnDownload\.click\s*\(|downloadLink\.click\s*\(|receivedDownloads\.[\w]+\([^)]*\)\.click\s*\(/ + ); }); -test('the newest received URL remains valid until explicit cleanup', () => { - const { lifecycle, revoked } = createHarness(); - lifecycle.install({ name: 'first' }, 'first.bin'); - const newestUrl = lifecycle.install({ name: 'second' }, 'second.bin'); +test('one received file creates one downloadable Blob URL', () => { + const { manager, created } = createHarness(); + const installed = manager.install({ size: 0 }, 'empty.bin'); + const [pending] = manager.getPendingItems(); - assert.equal(revoked.includes(newestUrl), false); - assert.equal(lifecycle.getActiveUrl(), newestUrl); + assert.equal(created.length, 1); + assert.equal(installed.url, 'blob:received-1'); + assert.equal(installed.size, 0); + assert.deepEqual(pending, installed); }); -test('application reset revokes the active received URL', () => { - const { lifecycle, link, revoked } = createHarness(); - const url = lifecycle.install({}, 'reset.bin'); - lifecycle.clear(); +test('two files retain independent URLs in reception order', () => { + const { manager, created, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); - assert.deepEqual(revoked, [url]); - assert.equal(lifecycle.getActiveUrl(), null); - assert.equal(link.href, '#'); - assert.match(appSource, /function resetApp\([^)]*\) \{\s*receivedBlobUrls\.clear\(\);/); + assert.equal(created.length, 2); + assert.notEqual(first.url, second.url); + assert.deepEqual(Array.from(manager.getItems(), (item) => item.fileName), ['first.bin', 'second.bin']); + assert.deepEqual(Array.from(manager.getPendingItems(), (item) => item.url), [first.url, second.url]); + assert.deepEqual(revoked, []); }); -test('starting send mode revokes the prior received URL', () => { - const { lifecycle, revoked } = createHarness(); - const url = lifecycle.install({}, 'received.bin'); - lifecycle.clear(); +test('installing a second file does not revoke the first URL', () => { + const { manager, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + manager.install({ size: 2 }, 'second.bin'); - assert.deepEqual(revoked, [url]); - assert.match( - appSource, - /webrtcManager\.onFileTransferStart = \([^)]*isSending[^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/ - ); + assert.equal(revoked.includes(first.url), false); + assert.equal(manager.getPendingItems()[0].url, first.url); }); -test('repeated received URL cleanup is idempotent', () => { - const { lifecycle, revoked } = createHarness(); - const url = lifecycle.install({}, 'once.bin'); +test('releasing one pending file removes only that file', () => { + const { manager, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); - assert.equal(lifecycle.clear(), true); - assert.equal(lifecycle.clear(), false); - assert.equal(lifecycle.clear(), false); - assert.deepEqual(revoked, [url]); + assert.equal(manager.release(first.id), true); + assert.deepEqual(revoked, [first.url]); + assert.deepEqual(Array.from(manager.getItems(), (item) => item.id), [second.id]); + assert.equal(manager.getPendingItems()[0].url, second.url); }); -test('direct-to-disk completion cannot retain a previous Blob URL', () => { - const { lifecycle, revoked } = createHarness(); - const url = lifecycle.install({}, 'memory.bin'); - lifecycle.clear(); +test('downloading one file revokes only its URL after the download starts', () => { + const { manager, revoked, scheduled, runScheduled } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); - assert.deepEqual(revoked, [url]); - assert.match(appSource, /if \(options\.savedToDisk\) \{\s*receivedBlobUrls\.clear\(\);/); + assert.equal(manager.startDownload(first.id), true); + assert.deepEqual(revoked, []); + assert.equal(scheduled.length, 1); + assert.equal(scheduled[0].delay, 1000); + assert.equal(manager.getItems()[0].status, 'downloaded'); + + runScheduled(); + + assert.deepEqual(revoked, [first.url]); + assert.equal(manager.getItems()[0].url, null); + assert.equal(manager.getPendingItems()[0].url, second.url); }); -test('cancellation, failure, and manual reset use centralized cleanup', () => { - assert.match(appSource, /webrtcManager\.onTransferError = \([^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/); - assert.match(appSource, /webrtcManager\.onFileTransferCancelled = \([^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/); - assert.match(appSource, /btnResetTransfer\.addEventListener\('click', \(\) => \{[\s\S]{0,250}receivedBlobUrls\.clear\(\);/); +test('double click cannot start the same download twice', () => { + const { manager, revoked, scheduled, runScheduled } = createHarness(); + const item = manager.install({ size: 3 }, 'once.bin'); + + assert.equal(manager.startDownload(item.id), true); + assert.equal(manager.startDownload(item.id), false); + assert.equal(scheduled.length, 1); + runScheduled(); + assert.deepEqual(revoked, [item.url]); }); -test('pagehide persisted in bfcache preserves the active received URL', () => { - const { lifecycle, revoked, link } = createHarness(); - const url = lifecycle.install({}, 'received.bin'); +test('downloading the first file does not invalidate the second', () => { + const { manager, revoked, runScheduled } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); - const cleared = lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle); + manager.startDownload(first.id); + runScheduled(); - assert.equal(cleared, false); - assert.deepEqual(revoked, []); - assert.equal(lifecycle.getActiveUrl(), url); - assert.equal(link.href, url); + assert.deepEqual(revoked, [first.url]); + assert.equal(manager.getPendingItems().length, 1); + assert.equal(manager.getPendingItems()[0].url, second.url); + assert.equal(manager.startDownload(second.id), true); }); -test('pagehide outside bfcache revokes the active received URL', () => { - const { lifecycle, revoked, link } = createHarness(); - const url = lifecycle.install({}, 'received.bin'); +test('pending item snapshots cannot mutate manager state', () => { + const { manager } = createHarness(); + manager.install({ size: 5 }, 'immutable.bin'); + const pending = manager.getPendingItems(); - const cleared = lifecycleHelpers.clearOnPageHide({ persisted: false }, lifecycle); + assert.equal(Object.isFrozen(pending), true); + assert.equal(Object.isFrozen(pending[0]), true); + assert.throws(() => pending.push({}), (error) => error?.name === 'TypeError'); + assert.throws( + () => { pending[0].status = 'downloaded'; }, + (error) => error?.name === 'TypeError' + ); + assert.equal(manager.getPendingItems()[0].status, 'ready'); +}); - assert.equal(cleared, true); - assert.deepEqual(revoked, [url]); - assert.equal(lifecycle.getActiveUrl(), null); - assert.equal(link.href, '#'); +test('full reset releases every received Blob URL', () => { + const { manager, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); + + assert.equal(manager.clearAll(), 2); + assert.deepEqual(revoked, [first.url, second.url]); + assert.equal(manager.getItems().length, 0); + assert.match(appSource, /function resetApp\([^)]*\) \{\s*clearReceivedDownloads\(\);/); +}); + +test('non-recoverable disconnect reaches the full received-download reset', () => { assert.match( appSource, - /window\.addEventListener\('pagehide', \(event\) => \{\s*clearReceivedBlobUrlOnPageHide\(event, receivedBlobUrls\);/ + /socketManager\.onDisconnect = \([^)]*\) => \{[\s\S]{0,300}if \(recoverable && roomCode\)[\s\S]{0,160}resetApp\(\);/ ); + assert.match(appSource, /function resetApp\([^)]*\) \{\s*clearReceivedDownloads\(\);/); }); -test('pageshow after bfcache restoration keeps the download link usable', () => { - const { lifecycle, revoked, link } = createHarness(); - const url = lifecycle.install({}, 'received.bin'); +test('normal pagehide releases all received URLs', () => { + const { manager, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); + + assert.equal(managerHelpers.clearOnPageHide({ persisted: false }, manager), true); + assert.deepEqual(revoked, [first.url, second.url]); + assert.equal(manager.getItems().length, 0); + assert.match( + appSource, + /window\.addEventListener\('pagehide', \(event\) => \{\s*clearReceivedDownloadsOnPageHide\(event, receivedDownloads\);/ + ); +}); - lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle); - const pageshowEvent = { persisted: true }; +test('bfcache pagehide preserves every received URL', () => { + const { manager, revoked } = createHarness(); + const first = manager.install({ size: 1 }, 'first.bin'); + const second = manager.install({ size: 2 }, 'second.bin'); - assert.equal(pageshowEvent.persisted, true); + assert.equal(managerHelpers.clearOnPageHide({ persisted: true }, manager), false); assert.deepEqual(revoked, []); - assert.equal(lifecycle.getActiveUrl(), url); - assert.equal(link.href, url); - assert.equal(link.attributes.get('download'), 'received.bin'); + assert.deepEqual( + Array.from(manager.getPendingItems(), (item) => item.url), + [first.url, second.url] + ); +}); + +test('direct-to-disk completion creates no Blob URL or download control', () => { + const completionStart = appSource.indexOf('webrtcManager.onFileTransferComplete'); + const diskStart = appSource.indexOf('if (options.savedToDisk)', completionStart); + const memoryStart = appSource.indexOf('} else if (fileBlob)', diskStart); + const diskBranch = appSource.slice(diskStart, memoryStart); + + assert.ok(completionStart >= 0 && diskStart > completionStart && memoryStart > diskStart); + assert.doesNotMatch(diskBranch, /createObjectURL|receivedDownloads\.install|btn_download/); + assert.match(diskBranch, /completedFileSize\.textContent = translate\('saved_to_disk'\)/); +}); + +test('delivery ACK remains before UI completion and independent from download clicks', () => { + const finalizeStart = webrtcSource.indexOf('async finalizeIncomingFile'); + const finalizeEnd = webrtcSource.indexOf('\n async failIncomingTransfer', finalizeStart); + const finalizeSource = webrtcSource.slice(finalizeStart, finalizeEnd); + const ackIndex = finalizeSource.indexOf("type: 'transfer-ack'"); + const completionIndex = finalizeSource.indexOf('this.onFileTransferComplete(fileBlob'); + + assert.ok(ackIndex >= 0, 'receiver finalization must emit transfer-ack'); + assert.ok(completionIndex > ackIndex, 'ACK must precede the UI completion callback'); + assert.doesNotMatch(finalizeSource, /startDownload|btnDownload|downloadLink/); + assert.doesNotMatch(appSource, /sendDeliveryControl\(\{\s*type: 'transfer-ack'/); }); -test('cleanup after bfcache restoration remains idempotent', () => { - const { lifecycle, revoked } = createHarness(); - const url = lifecycle.install({}, 'received.bin'); - lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle); +test('received-download interface includes complete English and Spanish copy', () => { + const requiredEnglish = [ + 'Received files', + 'Ready to download', + 'Download', + 'Downloaded', + 'Download pending files', + 'Transfer Another File' + ]; + const requiredSpanish = [ + 'Archivos recibidos', + 'Listo para descargar', + 'Descargar', + 'Descargado', + 'Descargar archivos pendientes', + 'Transferir otro archivo' + ]; - assert.equal(lifecycle.clear(), true); - assert.equal(lifecycle.clear(), false); - assert.deepEqual(revoked, [url]); + for (const text of [...requiredEnglish, ...requiredSpanish]) { + assert.ok(htmlSource.includes(text), `Missing received-download translation: ${text}`); + } + assert.match(htmlSource, /id="received-files-list"[^>]*aria-live="polite"/); });