mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
883 lines
31 KiB
JavaScript
883 lines
31 KiB
JavaScript
// 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 = '<pre class="x">code</pre>';
|
|
const out = wrapper.vm.processHtmlBlock(html);
|
|
expect(out).toContain('<span class="generated-code-block"');
|
|
expect(out).not.toContain('<pre');
|
|
});
|
|
|
|
it('formatTime returns localized string', async () => {
|
|
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 = [
|
|
'<pre>code</pre>',
|
|
'<pre class="a">b</pre>',
|
|
'<div>no change</div>',
|
|
];
|
|
samples.forEach((s, idx) => {
|
|
it(`processHtmlBlock sample ${idx}`, async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const out = wrapper.vm.processHtmlBlock(s);
|
|
expect(typeof out).toBe('string');
|
|
});
|
|
});
|
|
|
|
it('formatMessage handles plain and markdown', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const a = wrapper.vm.formatMessage('**bold**');
|
|
expect(typeof a).toBe('string');
|
|
});
|
|
});
|
|
|
|
// 14. Log entry expand/collapse
|
|
describe('O2AIChat - log entry expand/collapse', () => {
|
|
it('toggleLogEntryExpanded adds key to set on first call', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(wrapper.vm.isLogEntryExpanded(0, 1)).toBe(false);
|
|
wrapper.vm.toggleLogEntryExpanded(0, 1);
|
|
expect(wrapper.vm.isLogEntryExpanded(0, 1)).toBe(true);
|
|
});
|
|
|
|
it('toggleLogEntryExpanded removes key on second call', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.toggleLogEntryExpanded(2, 3);
|
|
expect(wrapper.vm.isLogEntryExpanded(2, 3)).toBe(true);
|
|
wrapper.vm.toggleLogEntryExpanded(2, 3);
|
|
expect(wrapper.vm.isLogEntryExpanded(2, 3)).toBe(false);
|
|
});
|
|
|
|
it('isLogEntryExpanded returns false for unknown keys', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(wrapper.vm.isLogEntryExpanded(99, 99)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// 15. formatLogEntryContent - JSON highlighting and plain text
|
|
describe('O2AIChat - formatLogEntryContent', () => {
|
|
it('formats valid JSON with syntax-highlighted spans', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const json = '{"name":"test","count":42,"active":true,"data":null}';
|
|
const result = wrapper.vm.formatLogEntryContent(json);
|
|
expect(result).toContain('json-key');
|
|
expect(result).toContain('json-string');
|
|
expect(result).toContain('json-number');
|
|
expect(result).toContain('json-boolean');
|
|
expect(result).toContain('json-null');
|
|
});
|
|
|
|
it('escapes HTML in non-JSON content', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const text = '<script>alert("xss")</script>';
|
|
const result = wrapper.vm.formatLogEntryContent(text);
|
|
expect(result).not.toContain('<script>');
|
|
expect(result).toContain('<script>');
|
|
});
|
|
|
|
it('handles plain text with newlines', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const text = 'line1\nline2';
|
|
const result = wrapper.vm.formatLogEntryContent(text);
|
|
expect(result).toContain('<br>');
|
|
});
|
|
|
|
it('pretty-prints JSON with indentation', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const json = '{"a":1}';
|
|
const result = wrapper.vm.formatLogEntryContent(json);
|
|
// Should contain formatted output (indented)
|
|
expect(result).toContain('json-key');
|
|
});
|
|
});
|
|
|
|
// 16. Image preview open/close
|
|
describe('O2AIChat - image preview', () => {
|
|
it('openImagePreview sets state', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const img = { data: 'abc123', mimeType: 'image/png', filename: 'test.png', size: 1024 };
|
|
wrapper.vm.openImagePreview(img);
|
|
expect(wrapper.vm.showImagePreview).toBe(true);
|
|
expect(wrapper.vm.previewImage).toEqual(img);
|
|
});
|
|
|
|
it('closeImagePreview clears state', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.showImagePreview = true;
|
|
wrapper.vm.previewImage = { data: 'x', mimeType: 'image/png', filename: 'x.png', size: 100 };
|
|
wrapper.vm.closeImagePreview();
|
|
expect(wrapper.vm.showImagePreview).toBe(false);
|
|
expect(wrapper.vm.previewImage).toBeNull();
|
|
});
|
|
});
|
|
|
|
// 17. Image management - pending images
|
|
describe('O2AIChat - image management', () => {
|
|
it('removeImage removes item at index', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.pendingImages = [
|
|
{ data: 'a', mimeType: 'image/png', filename: 'a.png', size: 100 },
|
|
{ data: 'b', mimeType: 'image/jpeg', filename: 'b.jpg', size: 200 },
|
|
];
|
|
wrapper.vm.removeImage(0);
|
|
expect(wrapper.vm.pendingImages.length).toBe(1);
|
|
expect(wrapper.vm.pendingImages[0].filename).toBe('b.jpg');
|
|
});
|
|
|
|
it('pendingImages starts empty', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(wrapper.vm.pendingImages).toEqual([]);
|
|
});
|
|
|
|
it('triggerImageUpload is a function', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(typeof wrapper.vm.triggerImageUpload).toBe('function');
|
|
});
|
|
});
|
|
|
|
// 18. handleNavigationAction
|
|
describe('O2AIChat - handleNavigationAction', () => {
|
|
it('pushes load_query with correct query params', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
await wrapper.vm.handleNavigationAction({
|
|
resource_type: 'logs',
|
|
action: 'load_query',
|
|
label: 'View in Logs',
|
|
target: {
|
|
query: 'SELECT * FROM test_stream',
|
|
sql_mode: true,
|
|
stream: ['test_stream'],
|
|
from: 1000000,
|
|
to: 2000000,
|
|
},
|
|
});
|
|
expect(mockRouterPush).toHaveBeenCalledTimes(1);
|
|
const call = mockRouterPush.mock.calls[0][0];
|
|
expect(call.path).toBe('/logs');
|
|
expect(call.query.stream_type).toBe('logs');
|
|
expect(call.query.sql_mode).toBe('true');
|
|
expect(call.query.type).toBe('ai_chat_query');
|
|
expect(call.query.from).toBe('1000000');
|
|
expect(call.query.to).toBe('2000000');
|
|
});
|
|
|
|
it('pushes load_query with VRL function params', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
await wrapper.vm.handleNavigationAction({
|
|
resource_type: 'logs',
|
|
action: 'load_query',
|
|
label: 'View in Logs',
|
|
target: {
|
|
query: 'SELECT * FROM stream1',
|
|
sql_mode: true,
|
|
stream: ['stream1'],
|
|
from: 100,
|
|
to: 200,
|
|
functionContent: '.field1 = "value"',
|
|
},
|
|
});
|
|
const call = mockRouterPush.mock.calls[0][0];
|
|
expect(call.query.fn_editor).toBe('true');
|
|
expect(call.query.functionContent).toBeTruthy();
|
|
});
|
|
|
|
it('pushes navigate_direct for alert resource', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
await wrapper.vm.handleNavigationAction({
|
|
resource_type: 'alert',
|
|
action: 'navigate_direct',
|
|
label: 'View Alert',
|
|
target: {
|
|
alert_id: 'alert-123',
|
|
name: 'Test Alert',
|
|
folder: 'default',
|
|
},
|
|
});
|
|
const call = mockRouterPush.mock.calls[0][0];
|
|
expect(call.path).toBe('/alerts');
|
|
expect(call.query.action).toBe('update');
|
|
expect(call.query.alert_id).toBe('alert-123');
|
|
});
|
|
|
|
it('pushes navigate_direct for dashboard resource', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
await wrapper.vm.handleNavigationAction({
|
|
resource_type: 'dashboard',
|
|
action: 'navigate_direct',
|
|
label: 'View Dashboard',
|
|
target: {
|
|
dashboard_id: 'dash-456',
|
|
folder: 'my-folder',
|
|
},
|
|
});
|
|
const call = mockRouterPush.mock.calls[0][0];
|
|
expect(call.path).toBe('/dashboards/view');
|
|
expect(call.query.dashboard).toBe('dash-456');
|
|
expect(call.query.folder).toBe('my-folder');
|
|
});
|
|
|
|
it('pushes navigate_direct for pipeline resource', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
await wrapper.vm.handleNavigationAction({
|
|
resource_type: 'pipeline',
|
|
action: 'navigate_direct',
|
|
label: 'View Pipeline',
|
|
target: {
|
|
pipeline_id: 'pipe-789',
|
|
name: 'My Pipeline',
|
|
},
|
|
});
|
|
const call = mockRouterPush.mock.calls[0][0];
|
|
expect(call.path).toBe('/pipeline/pipelines/edit');
|
|
expect(call.query.id).toBe('pipe-789');
|
|
expect(call.query.name).toBe('My Pipeline');
|
|
});
|
|
});
|
|
|
|
// 19. Tool confirmation handling
|
|
describe('O2AIChat - tool confirmation', () => {
|
|
it('handleToolConfirm and handleToolCancel are functions', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(typeof wrapper.vm.handleToolConfirm).toBe('function');
|
|
expect(typeof wrapper.vm.handleToolCancel).toBe('function');
|
|
});
|
|
|
|
it('handleToolConfirm resolves pending confirmation block', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
// Add a pending confirmation block
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: '',
|
|
contentBlocks: [{
|
|
type: 'tool_call',
|
|
tool: 'SearchSQL',
|
|
pendingConfirmation: true,
|
|
confirmationMessage: 'Run query?',
|
|
}],
|
|
}];
|
|
await wrapper.vm.handleToolConfirm();
|
|
await flushPromises();
|
|
// Confirm block should no longer be pending (resolveConfirmationBlock was called)
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[0].pendingConfirmation).toBe(false);
|
|
});
|
|
|
|
it('handleToolCancel resolves and marks block failed', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: '',
|
|
contentBlocks: [{
|
|
type: 'tool_call',
|
|
tool: 'CreateAlert',
|
|
pendingConfirmation: true,
|
|
confirmationMessage: 'Create alert?',
|
|
}],
|
|
}];
|
|
await wrapper.vm.handleToolCancel();
|
|
await flushPromises();
|
|
// Block should be marked as failed
|
|
const block = wrapper.vm.chatMessages[0].contentBlocks[0];
|
|
expect(block.pendingConfirmation).toBe(false);
|
|
expect(block.success).toBe(false);
|
|
expect(block.resultMessage).toBe('Action cancelled by user');
|
|
});
|
|
});
|
|
|
|
// 20. Drag and drop handlers
|
|
describe('O2AIChat - drag and drop', () => {
|
|
it('handleDragOver prevents default', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
|
wrapper.vm.handleDragOver(event);
|
|
expect(event.preventDefault).toHaveBeenCalled();
|
|
expect(event.stopPropagation).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handleDrop prevents default', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const event = {
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
dataTransfer: { files: [] },
|
|
};
|
|
await wrapper.vm.handleDrop(event);
|
|
expect(event.preventDefault).toHaveBeenCalled();
|
|
expect(event.stopPropagation).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handlePaste is a function', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(typeof wrapper.vm.handlePaste).toBe('function');
|
|
});
|
|
});
|
|
|
|
// 21. Tool call expanded state management
|
|
describe('O2AIChat - tool call expanded state', () => {
|
|
it('toggleToolCallExpanded toggles the expanded state', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(wrapper.vm.isToolCallExpanded(0, 0)).toBe(false);
|
|
wrapper.vm.toggleToolCallExpanded(0, 0);
|
|
expect(wrapper.vm.isToolCallExpanded(0, 0)).toBe(true);
|
|
wrapper.vm.toggleToolCallExpanded(0, 0);
|
|
expect(wrapper.vm.isToolCallExpanded(0, 0)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// 22. Content block rendering with new types
|
|
describe('O2AIChat - content block types', () => {
|
|
it('processedMessages handles error blocks', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: 'Error occurred',
|
|
contentBlocks: [
|
|
{ type: 'error', message: 'Search failed', suggestion: 'Try again', recoverable: true },
|
|
],
|
|
}];
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[0].type).toBe('error');
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[0].message).toBe('Search failed');
|
|
});
|
|
|
|
it('handles navigation blocks', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: '',
|
|
contentBlocks: [
|
|
{
|
|
type: 'navigation',
|
|
navigationAction: {
|
|
resource_type: 'logs',
|
|
action: 'load_query',
|
|
label: 'View in Logs',
|
|
target: { query: 'SELECT *', sql_mode: true },
|
|
},
|
|
},
|
|
],
|
|
}];
|
|
const block = wrapper.vm.chatMessages[0].contentBlocks[0];
|
|
expect(block.type).toBe('navigation');
|
|
expect(block.navigationAction.label).toBe('View in Logs');
|
|
});
|
|
|
|
it('handles tool_call blocks with success/failure states', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: '',
|
|
contentBlocks: [
|
|
{ type: 'tool_call', tool: 'SearchSQL', success: true, resultMessage: 'Found 10 records' },
|
|
{ type: 'tool_call', tool: 'CreateAlert', success: false, resultMessage: 'Permission denied', errorType: 'AuthError' },
|
|
],
|
|
}];
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[0].success).toBe(true);
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[1].success).toBe(false);
|
|
expect(wrapper.vm.chatMessages[0].contentBlocks[1].errorType).toBe('AuthError');
|
|
});
|
|
|
|
it('handles tool_call with summary data', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'assistant',
|
|
content: '',
|
|
contentBlocks: [
|
|
{
|
|
type: 'tool_call',
|
|
tool: 'SearchSQL',
|
|
success: true,
|
|
summary: { count: 42, took: 150 },
|
|
},
|
|
],
|
|
}];
|
|
const block = wrapper.vm.chatMessages[0].contentBlocks[0];
|
|
expect(block.summary.count).toBe(42);
|
|
expect(block.summary.took).toBe(150);
|
|
});
|
|
});
|
|
|
|
// 23. Messages with images
|
|
describe('O2AIChat - messages with images', () => {
|
|
it('user messages can have images array', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
wrapper.vm.chatMessages = [{
|
|
role: 'user',
|
|
content: 'Check this image',
|
|
images: [
|
|
{ data: 'base64data', mimeType: 'image/png', filename: 'screenshot.png', size: 50000 },
|
|
],
|
|
}];
|
|
expect(wrapper.vm.chatMessages[0].images.length).toBe(1);
|
|
expect(wrapper.vm.chatMessages[0].images[0].filename).toBe('screenshot.png');
|
|
});
|
|
});
|
|
|
|
// 24. Active tool call display and VRL context
|
|
describe('O2AIChat - active tool call VRL context', () => {
|
|
it('getToolCallDisplayData returns vrl field when present in context', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const context = {
|
|
vrl: '.status = "error"',
|
|
stream_name: 'stream1',
|
|
};
|
|
const displayData = wrapper.vm.getToolCallDisplayData(context);
|
|
expect(displayData.vrl).toBe('.status = "error"');
|
|
expect(displayData.stream).toBe('stream1');
|
|
});
|
|
|
|
it('getToolCallDisplayData extracts from request_body.query', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const context = {
|
|
request_body: {
|
|
query: {
|
|
sql: 'SELECT * FROM stream1',
|
|
start_time: 1000,
|
|
end_time: 2000,
|
|
vrl: '.field = "val"',
|
|
},
|
|
},
|
|
stream_name: 'stream1',
|
|
};
|
|
const displayData = wrapper.vm.getToolCallDisplayData(context);
|
|
expect(displayData.query).toBe('SELECT * FROM stream1');
|
|
expect(displayData.vrl).toBe('.field = "val"');
|
|
expect(displayData.stream).toBe('stream1');
|
|
});
|
|
|
|
it('getToolCallDisplayData returns null for empty context', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
expect(wrapper.vm.getToolCallDisplayData(null)).toBeNull();
|
|
expect(wrapper.vm.getToolCallDisplayData({})).toBeNull();
|
|
});
|
|
|
|
it('getToolCallDisplayData handles flat sql field', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const context = { sql: 'SELECT * FROM logs' };
|
|
const displayData = wrapper.vm.getToolCallDisplayData(context);
|
|
expect(displayData.query).toBe('SELECT * FROM logs');
|
|
});
|
|
|
|
it('getToolCallDisplayData handles request_body.function as vrl', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const context = {
|
|
request_body: { function: '.field1 = upcase(.field1)' },
|
|
};
|
|
const displayData = wrapper.vm.getToolCallDisplayData(context);
|
|
expect(displayData.vrl).toBe('.field1 = upcase(.field1)');
|
|
});
|
|
|
|
it('truncateQuery shortens long queries', async () => {
|
|
const wrapper = await mountChat({ isOpen: true });
|
|
const longQuery = 'SELECT ' + 'a,'.repeat(200) + ' FROM stream1';
|
|
const truncated = wrapper.vm.truncateQuery(longQuery);
|
|
expect(truncated.length).toBeLessThan(longQuery.length);
|
|
});
|
|
});
|