Files
openobserve/web/src/components/O2AIChat.spec.js
T

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('&lt;script&gt;');
});
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);
});
});