diff --git a/app/src/pages/__tests__/Conversations.attachments.test.tsx b/app/src/pages/__tests__/Conversations.attachments.test.tsx
index e24e569ce..8167f088c 100644
@@ -1,100 +1,104 @@
/**
* Attachment feature tests for Conversations.tsx — covers the new lines added
* for multimodal image attachments: handleAttachFiles, error display,
* attachment-only sends, and user bubble image rendering.
*/
import { combineReducers, configureStore } from '@reduxjs/toolkit';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
import agentProfileReducer from '../../store/agentProfileSlice';
[... 4 context line(s) omitted ...]
// ── Hoisted mock state ──────────────────────────────────────────────────────
+const TINY_PNG_DATA_URI = 'data:image/png;base64,iVBORw0KGgo=';
+const originalCreateObjectURL = URL.createObjectURL;
+const originalRevokeObjectURL = URL.revokeObjectURL;
+
const {
mockGetThreads,
mockGetThreadMessages,
[... 74 context line(s) omitted ...]
persistReaction: vi.fn().mockResolvedValue({}),
},
}));
@@ -190,180 +194,200 @@ vi.mock('../../lib/coreState/store', () => ({
// ── Helpers ─────────────────────────────────────────────────────────────────
[... 74 context line(s) omitted ...]
// ── Tests ────────────────────────────────────────────────────────────────────
describe('Conversations — attachment feature', () => {
+ let objectUrlCounter = 0;
+
beforeEach(() => {
vi.clearAllMocks();
+ objectUrlCounter = 0;
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: vi.fn(() => `blob:conversation-attachment-${++objectUrlCounter}`),
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() });
mockVisionState.vision = true;
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
[... 12 context line(s) omitted ...]
});
});
+ afterEach(() => {
+ cleanup();
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: originalCreateObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: originalRevokeObjectURL,
+ });
+ });
+
it('renders the attachment button in the composer', async () => {
await renderWithSelectedThread();
expect(screen.getByTitle('Attach file')).toBeInTheDocument();
[... 74 context line(s) omitted ...]
expect(screen.getByText(/8 MB/i)).toBeInTheDocument();
});
});
@@ -462,366 +486,368 @@ describe('Conversations — attachment feature', () => {
});
// The image is not attached, and the profile is left untouched.
expect(screen.queryByText('no-vision.png')).not.toBeInTheDocument();
[... 74 context line(s) omitted ...]
it('renders image thumbnails in user message bubble from extraMetadata', async () => {
const thread = makeThread({ id: 'img-thread', title: 'Img Thread' });
- const dataUri = 'data:image/png;base64,abc123';
+ const dataUri = TINY_PNG_DATA_URI;
const message = {
id: 'msg-1',
content: 'look at this',
[... 35 context line(s) omitted ...]
);
await waitFor(() => {
- const img = document.querySelector(`img[src="${dataUri}"]`);
+ const img = document.querySelector('img[src^="blob:conversation-attachment-"]');
expect(img).not.toBeNull();
});
+ expect(URL.createObjectURL).toHaveBeenCalled();
});
it('renders a document filename chip in the user bubble from attachmentKinds/Names', async () => {
[... 109 context line(s) omitted ...]
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const thread = makeThread({ id: 'legacy-thread', title: 'Legacy Thread' });
- const dataUri = 'data:image/png;base64,legacy123';
+ const dataUri = TINY_PNG_DATA_URI;
const message = {
id: 'msg-legacy-1',
content: `read this [IMAGE:${dataUri}] and [FILE:data:application/pdf;base64,xyz]`,
[... 36 context line(s) omitted ...]
// The image marker's data URI still renders as an <img> (parsed out for display)...
await waitFor(() => {
- const img = document.querySelector(`img[src="${dataUri}"]`);
+ const img = document.querySelector('img[src^="blob:conversation-attachment-"]');
expect(img).not.toBeNull();
});
+ expect(URL.createObjectURL).toHaveBeenCalled();
// ...but the raw marker syntax must never leak into the rendered bubble text.
expect(document.body.textContent).not.toContain('[IMAGE:');
[... 74 context line(s) omitted ...]
// unchanged must not dispatch an update.
fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
[compacted tool output — this is a PARTIAL view; the full original (25120 bytes) is available by calling tokenjuice_retrieve with token "35cd9dc70547ad3520168f43e27af3c1" (marker ⟦tj:35cd9dc70547ad3520168f43e27af3c1⟧)]