diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index d734a306..0cc0d46b 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -16,7 +16,7 @@ import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_ import { createVoiceConversation } from './voiceConversation.js'; import { getClientContext } from './clientContext.js'; import { createDictationController } from './dictation.js'; -import { isConversationImageFile, initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js"; +import { isConversationImageFile, initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, attachmentBlockMessage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js"; // Chat Tab Module import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js'; import { @@ -6802,9 +6802,9 @@ function updateSendButtonForCurrentChatState() { if (queueSubmissionInFlight) { sendButton.title = 'Saving this prompt to the conversation queue.'; } else if (imagePreparationBlocked && !uploadInFlight) { - sendButton.title = 'Remove the failed image attachment before sending.'; + sendButton.title = attachmentBlockMessage(activeChatSessionId); } else if (uploadInFlight) { - sendButton.title = ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE; + sendButton.title = attachmentBlockMessage(activeChatSessionId); } else { sendButton.title = sessionBusy ? 'Queue a separate request after the current turn finishes. Use Steer to guide the active turn.' @@ -21492,9 +21492,6 @@ let uploadUiState = { }; const PENDING_FILE_COPY_SESSION_FALLBACK_KEY = '__pending_chat_session__'; -const ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE = ( - 'Wait for the attachment upload to finish before sending.' -); const pendingFileCopyConceptIdsBySession = new Map(); const pendingFileCopyDisplayNamesBySession = new Map(); const uploadStatesBySession = new Map(); @@ -21502,7 +21499,8 @@ const uploadStatesBySession = new Map(); function getInFlightUploadStateForSession(sessionId = activeChatSessionId) { const sessionKey = getPendingFileCopySessionKey(sessionId); const uploadState = uploadStatesBySession.get(sessionKey) || null; - return uploadState?.inFlight === true ? uploadState : null; + // Once registered, individual attachments own readiness, not the batch promise. + return uploadState?.inFlight === true && !uploadState.itemsRegistered ? uploadState : null; } function normaliseTrustedUploadedFileCopyConceptId(value) { @@ -21569,7 +21567,7 @@ function refreshConversationImages() { syncConversationAttachmentWorkflowBinding(activeChatSessionId); renderPendingAttachmentState(); }); - updateSendButtonForCurrentChatState(); + renderActiveUploadUi(); } function syncConversationAttachmentWorkflowBinding(sessionId) { @@ -21611,24 +21609,6 @@ function renderPendingAttachmentState() { refreshAttachmentStatusRegionVisibility(); } -function rememberPendingUploadedFileCopyConceptId( - conceptId, - sessionId = activeChatSessionId, - displayName = null -) { - const normalisedConceptId = normaliseTrustedUploadedFileCopyConceptId(conceptId); - if (!normalisedConceptId) return; - const targetKey = getPendingFileCopySessionKey(sessionId); - pendingFileCopyConceptIdsBySession.set(targetKey, normalisedConceptId); - const normalisedDisplayName = normalisePendingAttachmentDisplayName(displayName); - if (normalisedDisplayName) { - pendingFileCopyDisplayNamesBySession.set(targetKey, normalisedDisplayName); - } else { - pendingFileCopyDisplayNamesBySession.delete(targetKey); - } - renderPendingAttachmentState(); -} - function takePendingUploadedFileCopyBinding(sessionId) { const targetKey = getPendingFileCopySessionKey(sessionId); const targetConceptId = pendingFileCopyConceptIdsBySession.get(targetKey) || null; @@ -21711,11 +21691,18 @@ function renderUploadStatus(message, type = 'info') { function renderActiveUploadUi() { const activeSessionKey = getPendingFileCopySessionKey(activeChatSessionId); const activeUploadState = uploadStatesBySession.get(activeSessionKey) || null; - renderUploadStatus( - activeUploadState?.message || '', - activeUploadState?.tone || 'info' - ); - setUploadButtonBusy(activeUploadState?.inFlight === true); + const items = imageItems(activeChatSessionId); + const unfinished = items.filter(item => item.state !== 'ready'); + const preparing = !!getInFlightUploadStateForSession(activeChatSessionId); + const uploading = unfinished.some(item => ['queued', 'uploading', 'retrying'].includes(item.state)); + const failed = unfinished.some(item => item.state === 'failed'); + const message = preparing ? activeUploadState.message + : failed ? attachmentBlockMessage(activeChatSessionId) + : uploading ? `Uploading: ${unfinished.map(item => item.name).join(', ')}` + : items.length ? `Upload complete: ${items.length} ready.` + : activeUploadState?.tone === 'error' ? activeUploadState.message : ''; + renderUploadStatus(message, failed ? 'error' : (preparing || uploading) ? 'uploading' : 'success'); + setUploadButtonBusy(preparing || uploading); updateSendButtonForCurrentChatState(); } @@ -21930,29 +21917,14 @@ async function performUploadFilesToVon(list, uploadState) { rekeyUploadStateForSession(uploadState, uploadTargetSessionId); } - let successCount = 0; - let failureCount = 0; - - for (const file of list) { - const uploaded = await uploadConversationImage(file, uploadTargetSessionId, buildChatFetchHeaders(), () => { - syncConversationAttachmentWorkflowBinding(uploadTargetSessionId); - renderPendingAttachmentState(); - }); - if (uploaded) { - successCount += 1; - if (!isConversationImageFile(file)) { - const item = imageItems(uploadTargetSessionId).findLast(item => item.name === file.name && item.descriptor); - if (item) rememberPendingUploadedFileCopyConceptId(item.descriptor.concept_id, uploadTargetSessionId, file.name); - } - } else failureCount += 1; - } - - const summary = `Upload complete: ${successCount} succeeded${failureCount ? `, ${failureCount} failed` : ''}.`; - setUploadStatusForState( - uploadState, - summary, - failureCount ? 'error' : 'success' - ); + // Register all files before yielding so unstarted members cannot escape the gate. + const uploads = list.map(file => uploadConversationImage(file, uploadTargetSessionId, buildChatFetchHeaders(), () => { + syncConversationAttachmentWorkflowBinding(uploadTargetSessionId); + renderPendingAttachmentState(); + })); + uploadState.itemsRegistered = true; + renderActiveUploadUi(); + await Promise.all(uploads); clearUploadStatusAfterDelay(uploadState); } @@ -21973,10 +21945,14 @@ function uploadFilesToVon(files) { const sessionKey = getPendingFileCopySessionKey(targetSessionId); const selectionFingerprint = buildUploadSelectionFingerprint(list); const existingState = uploadStatesBySession.get(sessionKey) || null; - if (existingState?.inFlight && existingState.promise) { + if (existingState?.promise && (getInFlightUploadStateForSession(targetSessionId) + || imageItems(targetSessionId).some(item => ['queued', 'uploading', 'retrying'].includes(item.state)))) { const repeatedSelection = ( existingState.selectionFingerprint === selectionFingerprint ); + if (!repeatedSelection) { + showToast('Another upload is already in progress; this additional selection was not uploaded.', 'info'); + } setUploadStatusForState( existingState, repeatedSelection @@ -37317,7 +37293,7 @@ async function handleSendPrompt(options = {}) { directComposerSubmission && (getInFlightUploadStateForSession(targetSessionId) || imagesBlocked(targetSessionId)) ) { - showToast(ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE, 'info'); + showToast(attachmentBlockMessage(targetSessionId), 'info'); updateSendButtonForCurrentChatState(); return; } diff --git a/src/frontend/web/von_interface/static/js/utils/conversationImages.js b/src/frontend/web/von_interface/static/js/utils/conversationImages.js index 41b37c96..44606952 100644 --- a/src/frontend/web/von_interface/static/js/utils/conversationImages.js +++ b/src/frontend/web/von_interface/static/js/utils/conversationImages.js @@ -1,8 +1,16 @@ // Durable attachment descriptors (historical image API); originals remain behind authenticated routes. +// Draft lifecycle: queued -> uploading -> ready | failed; failed -> retrying +// -> ready | failed. Removal is terminal and late transport results are ignored. +// Descriptors are payload, while state alone owns rendering and send readiness. const pending = new Map(); const known = new Map(); export function imageItems(sessionId) { return pending.get(sessionId) || []; } -export function imagesBlocked(sessionId) { return imageItems(sessionId).some(x => !x.descriptor); } +export function imagesBlocked(sessionId) { return imageItems(sessionId).some(x => x.state !== 'ready'); } +export function attachmentBlockMessage(sessionId) { + return imageItems(sessionId).some(x => x.state === 'failed') + ? 'Attachment upload failed. Retry or remove the failed attachment before sending.' + : 'Wait for the attachment upload to finish before sending.'; +} export function renderImageAttachments(parent, images) { if (!parent || !Array.isArray(images) || !images.length) return; const gallery = document.createElement('div'); @@ -68,7 +76,8 @@ export function renderImageComposer(parent, sessionId, changed) { for (const item of imageItems(sessionId)) { const card = document.createElement('div'); card.className = 'conversation-image-draft'; - if (item.descriptor) { + card.dataset.attachmentState = item.state; + if (item.state === 'ready') { renderImageAttachments(card, [item.descriptor]); card.querySelectorAll('img').forEach(img => { img.style.maxHeight = '100px'; }); } @@ -80,7 +89,7 @@ export function renderImageComposer(parent, sessionId, changed) { card.appendChild(preview); } const label = document.createElement('span'); - label.textContent = item.error ? `${item.name}: ${item.error}` : (item.descriptor ? item.name : `Uploading ${item.name}…`); + label.textContent = item.error ? `${item.name}: ${item.error}` : (item.state === 'ready' ? `Ready: ${item.name}` : `${item.state === 'retrying' ? 'Retrying' : 'Uploading'} ${item.name}…`); label.setAttribute('role', item.error ? 'alert' : 'status'); card.appendChild(label); const details = document.createElement('span'); @@ -96,6 +105,7 @@ export function renderImageComposer(parent, sessionId, changed) { const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Remove'; remove.setAttribute('aria-label', `Remove ${item.name}`); remove.onclick = () => { + item.state = 'removed'; item.controller?.abort(); pending.set(sessionId, imageItems(sessionId).filter(x => x !== item)); releasePreview(item); @@ -109,7 +119,7 @@ export function isConversationImageFile(file) { } export async function uploadConversationImage(file, sessionId, headers, changed) { const item = { - name: file.name, type: file.type, size: file.size, + name: file.name, type: file.type, size: file.size, state: 'queued', previewUrl: file.type.startsWith('image/') && typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null, }; item.retry = () => uploadItem(item, file, sessionId, headers, changed); @@ -121,8 +131,8 @@ function releasePreview(item) { item.previewUrl = null; } async function uploadItem(item, file, sessionId, headers, changed) { - if (item.uploading || !imageItems(sessionId).includes(item)) return false; - item.uploading = true; + if (['uploading', 'retrying', 'ready', 'removed'].includes(item.state) || !imageItems(sessionId).includes(item)) return false; + item.state = item.state === 'failed' ? 'retrying' : 'uploading'; item.controller = new AbortController(); item.error = null; changed(); @@ -143,16 +153,23 @@ async function uploadItem(item, file, sessionId, headers, changed) { const descriptor = result.image_attachment || (result.uploaded?.concept_id ? {...result.uploaded, filename: file.name, content_type: file.type || 'application/octet-stream', size_bytes: file.size} : null); if (!response.ok || !descriptor) throw new Error(result.message || result.error || 'Attachment upload failed'); // Removing an in-flight upload must not restore it when the response arrives. - if (imageItems(sessionId).includes(item)) { + if (item.state !== 'removed' && imageItems(sessionId).includes(item)) { + if (typeof descriptor.concept_id !== 'string' || !/^#V#\S+$/.test(descriptor.concept_id)) throw new Error('Upload returned no valid attachment identifier'); item.descriptor = descriptor; + item.state = 'ready'; item.retry = () => Promise.resolve(true); known.set(item.descriptor.concept_id, item); } releasePreview(item); - } catch (error) { item.error = String(error.message || error); } - item.uploading = false; + } catch (error) { + if (item.state !== 'removed' && item.state !== 'ready') { + item.state = 'failed'; + item.error = String(error.message || error); + } + } + item.controller = null; changed(); - return Boolean(item.descriptor); + return item.state === 'ready'; } // Both controls use the existing session-bound upload path supplied by chatTab. export function initialiseImagePicker(upload, status) { diff --git a/tests/browser/attachmentLifecycle.cjs b/tests/browser/attachmentLifecycle.cjs new file mode 100644 index 00000000..0408ea20 --- /dev/null +++ b/tests/browser/attachmentLifecycle.cjs @@ -0,0 +1,57 @@ +// Local Chromium component acceptance: actual attachment module, fixture transport. +// Does not use server authentication, upload real data, or invoke a model. +process.env.PLAYWRIGHT_BROWSERS_PATH ||= '/tmp/von-playwright'; +const { chromium, expect } = require('@playwright/test'); +const fs = require('node:fs'); +const path = require('node:path'); +const output = process.argv[2] || '.run/attachment-lifecycle'; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://attachment.test/**', route => { + if (route.request().url().endsWith('/module.js')) return route.fulfill({ + contentType: 'text/javascript', + body: fs.readFileSync(path.join(__dirname, '../../src/frontend/web/von_interface/static/js/utils/conversationImages.js'), 'utf8') + }); + return route.fulfill({ contentType: 'text/html', body: '

' }); + }); + await page.goto('http://attachment.test/'); + await page.evaluate(async () => { + const api = await import('/module.js'); + const root = document.getElementById('attachments'); + const refresh = () => { + api.renderImageComposer(root, 'draft', refresh); + document.getElementById('send').disabled = api.imagesBlocked('draft'); + document.getElementById('status').textContent = api.imagesBlocked('draft') ? api.attachmentBlockMessage('draft') : 'Ready to send'; + }; + window.api = api; + window.fetch = async (_, options) => { + const name = options.body.get('file').name; + if (name === 'program.pdf') return new Promise(resolve => { window.finish = resolve; }); + return { ok: true, json: async () => ({ uploaded: { concept_id: '#V#poster', sha256: 'fixture-sha' } }) }; + }; + await api.uploadConversationImage(new File(['poster'], 'poster.pdf', { type: 'application/pdf' }), 'draft', {}, refresh); + window.slow = api.uploadConversationImage(new File(['program'], 'program.pdf', { type: 'application/pdf' }), 'draft', {}, refresh); + }); + await expect(page.locator('[data-attachment-state="ready"]')).toContainText('poster.pdf'); + await expect(page.locator('[data-attachment-state="uploading"]')).toContainText('program.pdf'); + await expect(page.locator('#send')).toBeDisabled(); + await page.getByRole('button', { name: 'Remove program.pdf', exact: true }).click(); + await expect(page.locator('#send')).toBeEnabled(); + await expect(page.locator('#status')).toHaveText('Ready to send'); + await page.screenshot({ path: path.join(output, 'ready-after-removal.png') }); + const result = await page.evaluate(async () => { + const ids = api.takeImages('draft'); + finish({ ok: true, json: async () => ({ uploaded: { concept_id: '#V#late' } }) }); + await slow; + return { ids, descriptors: api.descriptorsForIds(ids), remaining: api.imageItems('draft').length }; + }); + expect(result.ids).toEqual(['#V#poster']); + expect(result.descriptors[0]).toMatchObject({ filename: 'poster.pdf', content_type: 'application/pdf', size_bytes: 6, sha256: 'fixture-sha' }); + expect(result.remaining).toBe(0); + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify({ method: 'Chromium actual attachment component with local fixture transport', ...result }, null, 2)); + console.log('PASS: ready/uploading status, removal unblocks send, late completion ignored, metadata preserved'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/frontend/chatAttachmentWorkflowInput.test.js b/tests/frontend/chatAttachmentWorkflowInput.test.js index 8ef54e1b..1237e862 100644 --- a/tests/frontend/chatAttachmentWorkflowInput.test.js +++ b/tests/frontend/chatAttachmentWorkflowInput.test.js @@ -31,6 +31,7 @@ jest.mock('../../src/frontend/web/von_interface/static/js/utils/textDecorator.js describe('chat attachment workflow input binding', () => { beforeEach(() => { + jest.resetModules(); window.scrollTo = jest.fn(); document.body.innerHTML = `
@@ -76,6 +77,76 @@ describe('chat attachment workflow input binding', () => { delete global.fetch; }); + test('removing the second uploading PDF unblocks the ready PDF before late completion', async () => { + const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); + const { imageItems } = require('../../src/frontend/web/von_interface/static/js/utils/conversationImages.js'); + const bodies = []; + let finish; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/files/upload') { + if (options.body.get('file').name === 'program.pdf') return new Promise(resolve => { finish = resolve; }); + return { ok: true, json: async () => ({ uploaded: { concept_id: '#V#poster' } }) }; + } + if (String(url).startsWith('/von/generate')) { + bodies.push(JSON.parse(options.body)); + return { ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) }; + } + if (String(url).startsWith('/von/history/length')) return { ok: true, json: async () => ({ history_length: 0, authenticated: true }) }; + return { ok: true, json: async () => ({}) }; + }); + document.getElementById('promptInput').value = 'Tell me about these talks'; + const upload = __testOnly_uploadFilesToVon([ + new File(['poster'], 'poster.pdf', { type: 'application/pdf' }), + new File(['program'], 'program.pdf', { type: 'application/pdf' }) + ]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain('poster.pdf'); + expect(document.getElementById('sendButton').disabled).toBe(true); + await sendMessage(); + expect(bodies).toHaveLength(0); + document.querySelector('[aria-label="Remove program.pdf"]').click(); + expect(document.getElementById('sendButton').disabled).toBe(false); + expect(document.getElementById('uploadFileStatus').textContent).not.toContain('Uploading'); + await sendMessage(); + expect(bodies[0].image_attachment_ids).toEqual(['#V#poster']); + expect(bodies[0].workflow_inputs.file_copy_concept_id).toBe('#V#poster'); + finish({ ok: true, json: async () => ({ uploaded: { concept_id: '#V#late-program' } }) }); + await upload; + expect(imageItems('attachment-session')).toEqual([]); + expect(document.getElementById('pendingAttachmentStatus').classList.contains('hidden')).toBe(true); + }, 15000); + + test('failed PDF gives a retry gate and successful retry immediately enables send', async () => { + const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); + let attempt = 0; + const bodies = []; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/files/upload') { + if (++attempt === 1) throw new Error('Connection lost'); + return { ok: true, json: async () => ({ uploaded: { concept_id: '#V#retry-pdf' } }) }; + } + if (String(url).startsWith('/von/generate')) { + bodies.push(JSON.parse(options.body)); + return { ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) }; + } + return { ok: true, json: async () => ({ history_length: 0, authenticated: true }) }; + }); + await __testOnly_uploadFilesToVon([new File(['pdf'], 'retry.pdf', { type: 'application/pdf' })]); + const send = document.getElementById('sendButton'); + expect(send.disabled).toBe(true); + expect(send.title).toContain('Retry or remove'); + expect(document.getElementById('uploadFileStatus').textContent).toContain('Retry or remove'); + document.getElementById('promptInput').value = 'Read the PDF'; + await sendMessage(); + expect(bodies).toHaveLength(0); + document.querySelector('[aria-label="Retry upload of retry.pdf"]').click(); + expect(send.disabled).toBe(true); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(send.disabled).toBe(false); + await sendMessage(); + expect(bodies[0].image_attachment_ids).toEqual(['#V#retry-pdf']); + }, 15000); + test('phone image without MIME binds only to its next request, after preview and removal', async () => { const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); const bodies = []; @@ -294,7 +365,7 @@ describe('chat attachment workflow input binding', () => { expect(promptInput.value).toBe('Keep this draft exactly as written.'); expect(document.getElementById('chatAttachmentStatus').classList.contains('hidden')).toBe(false); expect(document.getElementById('uploadFileStatus').textContent).toContain( - 'already in progress' + 'Uploading: slow-supervision.xlsx' ); expect(document.getElementById('uploadFileButton').disabled).toBe(true); diff --git a/tests/frontend/conversationImageRecovery.test.js b/tests/frontend/conversationImageRecovery.test.js index 95ab498c..2067cf76 100644 --- a/tests/frontend/conversationImageRecovery.test.js +++ b/tests/frontend/conversationImageRecovery.test.js @@ -16,10 +16,12 @@ test('a failed upload retains the file and retries in the same draft without dup .mockResolvedValueOnce(response('#V#retried')); expect(await uploadConversationImage(file, 'retry', {}, changed)).toBe(false); expect(imagesBlocked('retry')).toBe(true); + expect(imageItems('retry')[0].state).toBe('failed'); expect(parent.textContent).toContain('image/png · 5 bytes'); expect(parent.textContent).toContain('Network unavailable'); const retry = parent.querySelector('[aria-label="Retry upload of diagram.png"]'); retry.click(); + expect(imageItems('retry')[0].state).toBe('retrying'); // A stale double-click cannot launch a second in-flight retry. retry.click(); await new Promise(resolve => setTimeout(resolve, 0)); @@ -64,3 +66,49 @@ test('plain text paste stays native and a send acknowledgement preserves newer f removeSentAttachments('acknowledgement', ['#V#sent']); expect(imageItems('acknowledgement').map(i=>i.descriptor.concept_id)).toEqual(['#V#newer']); }); + +test('ready is terminal despite stale retry callbacks and duplicate transport settlement', async () => { + let finish; + let reject; + global.fetch = jest.fn(() => new Promise((resolve, fail) => { finish = resolve; reject = fail; })); + const upload = uploadConversationImage(new File(['x'], 'ready.pdf'), 'terminal', {}, () => {}); + const item = imageItems('terminal')[0]; + const staleRetry = item.retry; + expect(item.state).toBe('uploading'); + finish(response('#V#ready')); + await upload; + reject(new Error('late failure')); + finish(response('#V#duplicate')); + await staleRetry(); + expect(item.state).toBe('ready'); + expect(item.error).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(takeImages('terminal')).toEqual(['#V#ready']); +}); + +test.each(['resolve', 'reject'])('removed upload ignores late %s after a replacement completes', async outcome => { + let finish, fail; + global.fetch = jest.fn().mockImplementationOnce(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; })) + .mockResolvedValueOnce(response('#V#replacement')); + const parent = document.createElement('div'); + const session = `late-${outcome}`; + const changed = () => renderImageComposer(parent, session, changed); + const upload = uploadConversationImage(new File(['x'], 'old.pdf'), session, {}, changed); + const item = imageItems(session)[0]; + parent.querySelector('button').click(); + expect(item.state).toBe('removed'); + expect(imagesBlocked(session)).toBe(false); + await uploadConversationImage(new File(['y'], 'new.pdf'), session, {}, changed); + if (outcome === 'resolve') finish(response('#V#old')); + else fail(new DOMException('Cancelled', 'AbortError')); + expect(await upload).toBe(false); + expect(item.state).toBe('removed'); + expect(takeImages(session)).toEqual(['#V#replacement']); +}); + +test('an invalid successful response remains failed and cannot send an unavailable attachment', async () => { + global.fetch = jest.fn().mockResolvedValue(response('')); + await uploadConversationImage(new File(['x'], 'invalid.pdf'), 'invalid', {}, () => {}); + expect(imageItems('invalid')[0].state).toBe('failed'); + expect(() => takeImages('invalid')).toThrow(); +});