Files
openobserve/web/src/components/O2AIChat.spec.js
T
Harsh Mahajanandgithub-actions[bot] 4f17833813 feat: server-side AI chat persistence and sharing (#14305)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-13 22:38:15 +00:00

931 lines
32 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
describe("O2AIChat - basic rendering", () => {
it("renders container when closed", async () => {
const wrapper = await mountChat({ isOpen: false });
expect(wrapper.find(".chat-container").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 });
// 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", 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");
});
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. 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.
describe("O2AIChat - theming", () => {
it("renders the same markup under either theme, with no per-theme class", async () => {
for (const theme of ["light", "dark"]) {
store.state.theme = theme;
const wrapper = await mountChat({ isOpen: true });
expect(wrapper.find(".chat-container").exists()).toBe(true);
expect(wrapper.find(".light-mode").exists()).toBe(false);
expect(wrapper.find(".dark-mode").exists()).toBe(false);
}
});
});
// 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);
});
});