Files

632 lines
22 KiB
JavaScript

// Copyright 2026 OpenObserve Inc.
// O2AIChat.vue unit tests (50+)
import { describe, it, expect, beforeAll, beforeEach, 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: {} } },
}),
// The component watches route.fullPath to hand a live stream off to the
// sidebar instance on navigation, so this mock must expose fullPath.
useRoute: () => ({ fullPath: "/", path: "/", name: "home", query: {}, params: {} }),
}));
// 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) => {
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: () => {
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),
chatHistoryServer: vi.fn(() => ({ enabled: () => false })),
}),
}));
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
// 2. Buttons and events
describe("O2AIChat - header actions", () => {
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,
);
});
});
// 3. History storage and search
describe("O2AIChat - history save/load/search", () => {
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", wrapper.vm.t);
// 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", wrapper.vm.t);
// 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");
});
});
// 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);
});
});
// 11. Capabilities panel helpers
// 12. Theming
// The component no longer emits per-theme `.light-mode` / `.dark-mode` classes.
// Colours now come from semantic design tokens that flip on the root `.dark`
// class, so the rendered markup is theme-independent.
// 13. Multiple micro-validations to exceed 50 tests
// 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);
});
});
// 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>");
});
});
// 16. Image preview open/close
// 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");
});
});
// 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 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
// 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
// 23. Messages with images
// 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);
});
});
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);
});
});