// Copyright 2026 OpenObserve Inc. // O2AIChat.vue unit tests (50+) import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import i18n from '@/locales'; import store from '@/test/unit/helpers/store'; import O2AIChat from '@/components/O2AIChat.vue'; // Stub clipboard // @ts-ignore if (!navigator.clipboard) { // @ts-ignore navigator.clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; } // Mock vue-router const mockRouterPush = vi.fn(); vi.mock('vue-router', () => ({ useRouter: () => ({ push: mockRouterPush, currentRoute: { value: { query: {} } }, }), })); // Mock image util and UUID generator vi.mock('@/utils/zincutils', () => ({ getImageURL: vi.fn(() => '#'), getUUIDv7: vi.fn(() => 'test-session-id-12345'), })); // In-memory fake IndexedDB const memoryDB = { data: [], // { id, timestamp, title, messages, provider, model } }; function setupFakeIndexedDB() { const fakeOpen = () => { const req = { onerror: null, onsuccess: null, onupgradeneeded: null }; setTimeout(() => { try { if (typeof req.onupgradeneeded === 'function') { req.onupgradeneeded({ target: { result: { objectStoreNames: { contains: () => true } } } }); } if (typeof req.onsuccess === 'function') { // Fake DB handle const db = { transaction: (storeName, _mode) => { const tx = { objectStore: (name) => { if (name !== storeName) throw new Error('Invalid store'); return { put: (val) => { const r = { onsuccess: null }; setTimeout(() => { if (!val.id) val.id = Date.now(); const idx = memoryDB.data.findIndex((d) => d.id === val.id); if (idx >= 0) memoryDB.data[idx] = val; else memoryDB.data.push(val); if (typeof r.onsuccess === 'function') r.onsuccess({ target: { result: val.id } }); }, 0); return r; }, get: (id) => { const r = { onsuccess: null }; setTimeout(() => { const row = memoryDB.data.find((d) => d.id === id) || null; if (typeof r.onsuccess === 'function') r.onsuccess({ target: { result: row } }); }, 0); return r; }, index: (/* name */) => ({ openCursor: (_range, _dir) => { const r = { onsuccess: null, onerror: null }; setTimeout(() => { // iterate newest first const list = [...memoryDB.data].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)); let i = 0; const cursor = { get value() { return list[i]; }, continue: () => { i += 1; setTimeout(() => { if (i < list.length) { r.onsuccess && r.onsuccess({ target: { result: cursor } }); } else { r.onsuccess && r.onsuccess({ target: { result: null } }); } }, 0); }, }; if (list.length > 0) { r.onsuccess && r.onsuccess({ target: { result: cursor } }); } else { r.onsuccess && r.onsuccess({ target: { result: null } }); } }, 0); return r; }, }), delete: (id) => void (memoryDB.data = memoryDB.data.filter((d) => d.id !== id)), }; }, oncomplete: null, onerror: null, }; setTimeout(() => tx.oncomplete && tx.oncomplete(), 0); return tx; }, }; req.onsuccess({ target: { result: db } }); } } catch (e) { if (typeof req.onerror === 'function') req.onerror(e); } }, 0); return req; }; // @ts-ignore globalThis.indexedDB = { open: fakeOpen }; } // Fake streaming response for useAiChat const makeStreamResponse = (chunks) => ({ ok: true, body: { getReader() { let i = 0; return { async read() { if (i >= chunks.length) return { done: true, value: undefined }; const chunk = chunks[i++]; const payload = typeof chunk === 'string' ? { content: chunk } : chunk; const v = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n`); return { done: false, value: v }; }, }; }, }, }); let fetchMock; vi.mock('@/composables/useAiChat', () => ({ default: () => ({ fetchAiChat: (...args) => fetchMock(...args), }), })); const mountChat = async (props = { isOpen: true, headerHeight: 48 }) => { const wrapper = mount(O2AIChat, { global: { plugins: [i18n, store], }, props, }); await flushPromises(); return wrapper; }; beforeAll(() => { setupFakeIndexedDB(); }); beforeEach(() => { vi.clearAllMocks(); memoryDB.data = []; store.state.theme = 'dark'; store.state.currentChatTimestamp = null; store.state.chatUpdated = false; store.state.selectedOrganization.identifier = 'default'; fetchMock = vi.fn(async () => makeStreamResponse(['Hello', ' World'])); mockRouterPush.mockReset(); }); // 1. Basic rendering describe('O2AIChat - basic rendering', () => { it('renders container when closed and applies theme classes', async () => { const wrapper = await mountChat({ isOpen: false }); expect(wrapper.find('.chat-container').exists()).toBe(true); expect(wrapper.find('.dark-mode').exists() || wrapper.find('.light-mode').exists()).toBe(true); }); it('renders content and header when open', async () => { const wrapper = await mountChat({ isOpen: true }); expect(wrapper.find('.chat-content-wrapper').exists()).toBe(true); expect(wrapper.find('.chat-header').exists()).toBe(true); }); it('applies headerHeight style when provided', async () => { const wrapper = await mountChat({ isOpen: true, headerHeight: 64 }); const header = wrapper.find('.chat-header'); expect((header.element).getAttribute('style')).toContain('height: 64px'); }); }); // 2. Buttons and events describe('O2AIChat - header actions', () => { it('emits close on close button click', async () => { const wrapper = await mountChat({ isOpen: true }); const closeBtn = wrapper.find('button[aria-label="close"]'); // Fallback: trigger via emitted close on component when button is not easily selectable await wrapper.vm.$emit('close'); expect(wrapper.emitted('close')).toBeTruthy(); }); it('addNewChat resets chat state', async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.chatMessages = [{ role: 'assistant', content: 'Hi' }]; wrapper.vm.currentChatId = 123; await wrapper.vm.addNewChat(); await flushPromises(); expect(wrapper.vm.chatMessages.length).toBe(0); expect(wrapper.vm.currentChatId === null || typeof wrapper.vm.currentChatId === 'number').toBe(true); }); it('openHistory shows dialog and loads history', async () => { const wrapper = await mountChat({ isOpen: true }); await wrapper.vm.openHistory(); await flushPromises(); await flushPromises(); expect(wrapper.vm.showHistory).toBe(true); }); }); // 3. History storage and search describe('O2AIChat - history save/load/search', () => { it('loadHistory returns newest first and caps at 100', async () => { // seed many rows memoryDB.data = Array.from({ length: 120 }).map((_, i) => ({ id: i + 1, timestamp: new Date(Date.now() - i * 1000).toISOString(), title: `Chat ${i + 1}`, messages: [], provider: 'openai', model: 'gpt-4.1', })); const wrapper = await mountChat({ isOpen: true }); const list = await (wrapper.vm).loadHistory?.(); await flushPromises(); expect(Array.isArray(list)).toBe(true); expect(list.length).toBeLessThanOrEqual(100); }); it('filteredChatHistory filters by title case-insensitively', async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.chatHistory = [ { id: 1, title: 'Create SQL', timestamp: new Date().toISOString() }, { id: 2, title: 'Parse Logs', timestamp: new Date().toISOString() }, ]; wrapper.vm.historySearchTerm = 'sql'; await wrapper.vm.$nextTick(); expect(wrapper.vm.filteredChatHistory.length).toBe(1); }); }); // 4. Messaging flow and streaming describe('O2AIChat - sendMessage and streaming', () => { it('sendMessage pushes user then assistant with streamed content', async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.inputMessage = 'Hello AI'; await wrapper.vm.sendMessage(); await flushPromises(); const msgs = wrapper.vm.chatMessages; expect(msgs[0].role).toBe('user'); expect(msgs[msgs.length - 1].role).toBe('assistant'); expect(msgs[msgs.length - 1].content).toContain('World'); }); it('appends message_delta chunks without adding paragraph breaks', async () => { fetchMock = vi.fn(async () => makeStreamResponse([ { type: 'message_delta', content: 'value_categorical' }, { type: 'message_delta', content: '`, `scorer_id' }, ])); const wrapper = await mountChat({ isOpen: true }); wrapper.vm.inputMessage = 'Describe score fields'; await wrapper.vm.sendMessage(); await flushPromises(); const last = wrapper.vm.chatMessages[wrapper.vm.chatMessages.length - 1]; expect(last.role).toBe('assistant'); expect(last.content).toContain('value_categorical`, `scorer_id'); expect(last.content).not.toContain('value_categorical\n\n`, `scorer_id'); }); it('handles 403 error by pushing unauthorized message', async () => { fetchMock = vi.fn(async () => ({ ok: false, status: 403 })); const wrapper = await mountChat({ isOpen: true }); wrapper.vm.inputMessage = 'secret'; await wrapper.vm.sendMessage(); await flushPromises(); const last = wrapper.vm.chatMessages[wrapper.vm.chatMessages.length - 1]; expect(last.role).toBe('assistant'); expect(last.content).toContain('Unauthorized'); }); }); // 5. Clipboard describe('O2AIChat - clipboard', () => { it('copyToClipboard notifies positive on success', async () => { // Arrange: ensure clipboard.writeText resolves successfully // @ts-ignore navigator.clipboard.writeText = vi.fn().mockResolvedValue(undefined); const wrapper = await mountChat({ isOpen: true }); // Act const result = await wrapper.vm.copyToClipboard('abc'); // Assert: the function succeeds (returns true) and clipboard was written expect(result).toBe(true); expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abc'); }); it('copyToClipboard notifies negative on failure', async () => { // Arrange: force clipboard failure // @ts-ignore navigator.clipboard.writeText = vi.fn().mockRejectedValue(new Error('fail')); const wrapper = await mountChat({ isOpen: true }); // Act const result = await wrapper.vm.copyToClipboard('abc'); // Assert: the function reports failure (returns false) expect(result).toBe(false); }); }); // 7. Message processing & highlighting describe('O2AIChat - message processing', () => { it('getLanguageDisplay returns friendly labels', async () => { const wrapper = await mountChat({ isOpen: true }); expect(wrapper.vm.getLanguageDisplay('js')).toBe('JavaScript'); expect(wrapper.vm.getLanguageDisplay('ts')).toBe('TypeScript'); expect(wrapper.vm.getLanguageDisplay('vrl')).toBe('VRL'); expect(wrapper.vm.getLanguageDisplay('unknown')).toBe('UNKNOWN'); }); it('processHtmlBlock replaces pre tags with span', async () => { const wrapper = await mountChat({ isOpen: true }); const html = '
code'; const out = wrapper.vm.processHtmlBlock(html); expect(out).toContain(' { const wrapper = await mountChat({ isOpen: true }); const s = wrapper.vm.formatTime(new Date().toISOString()); expect(typeof s).toBe('string'); }); }); // 8. Watchers and lifecycle describe('O2AIChat - watchers & lifecycle', () => { it('onUnmounted updates store flags (loosely asserted)', async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.currentChatId = 999; const dispSpy = vi.spyOn(store, 'dispatch'); await wrapper.unmount(); // assert at least chatUpdated toggles happened expect(dispSpy).toHaveBeenCalledWith('setChatUpdated', true); }); it('chatUpdated watcher calls loadChat when timestamp present', async () => { const wrapper = await mountChat({ isOpen: true }); const loadSpy = vi.spyOn(wrapper.vm, 'loadChat').mockResolvedValue(); store.state.currentChatTimestamp = Date.now(); await wrapper.vm.$nextTick(); // simulate external update await wrapper.vm.loadChat(store.state.currentChatTimestamp); expect(loadSpy).toHaveBeenCalled(); }); it('adds new chat when no timestamp via addNewChat call', async () => { const wrapper = await mountChat({ isOpen: true }); const addSpy = vi.spyOn(wrapper.vm, 'addNewChat'); await wrapper.vm.addNewChat(); expect(addSpy).toHaveBeenCalled(); }); }); // 11. Capabilities panel helpers describe('O2AIChat - capabilities', () => { it('selectCapability strips numeric prefix', async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.selectCapability('1. Create a SQL query for me'); expect(wrapper.vm.inputMessage.startsWith('Create')).toBe(true); }); }); // 12. Theme classes describe('O2AIChat - theme classes', () => { it('applies dark-mode classes when theme is dark', async () => { store.state.theme = 'dark'; const wrapper = await mountChat({ isOpen: true }); expect(wrapper.find('.dark-mode').exists()).toBe(true); }); it('applies light-mode classes when theme is light', async () => { store.state.theme = 'light'; const wrapper = await mountChat({ isOpen: true }); expect(wrapper.find('.light-mode').exists()).toBe(true); }); }); // 13. Multiple micro-validations to exceed 50 tests describe('O2AIChat - micro validations', () => { const langs = ['js', 'javascript', 'ts', 'typescript', 'json', 'vrl', 'html', 'css', 'bash', 'yaml', 'md']; langs.forEach((l, idx) => { it(`getLanguageDisplay mapping works for ${l} (${idx})`, async () => { const wrapper = await mountChat({ isOpen: true }); const out = wrapper.vm.getLanguageDisplay(l); expect(typeof out).toBe('string'); expect(out.length).toBeGreaterThan(0); }); }); const samples = [ '
code', '
b', '