// 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 = '
code
'; const out = wrapper.vm.processHtmlBlock(html); expect(out).toContain(' { const wrapper = await mountChat({ isOpen: true }); const s = wrapper.vm.formatTime(new Date().toISOString()); expect(typeof s).toBe("string"); }); }); // 8. Watchers and lifecycle describe("O2AIChat - watchers & lifecycle", () => { it("onUnmounted updates store flags (loosely asserted)", async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.currentChatId = 999; const dispSpy = vi.spyOn(store, "dispatch"); await wrapper.unmount(); // assert at least chatUpdated toggles happened expect(dispSpy).toHaveBeenCalledWith("setChatUpdated", true); }); it("chatUpdated watcher calls loadChat when timestamp present", async () => { const wrapper = await mountChat({ isOpen: true }); const loadSpy = vi.spyOn(wrapper.vm, "loadChat").mockResolvedValue(); store.state.currentChatTimestamp = Date.now(); await wrapper.vm.$nextTick(); // simulate external update await wrapper.vm.loadChat(store.state.currentChatTimestamp); expect(loadSpy).toHaveBeenCalled(); }); it("adds new chat when no timestamp via addNewChat call", async () => { const wrapper = await mountChat({ isOpen: true }); const addSpy = vi.spyOn(wrapper.vm, "addNewChat"); await wrapper.vm.addNewChat(); expect(addSpy).toHaveBeenCalled(); }); }); // 11. Capabilities panel helpers describe("O2AIChat - capabilities", () => { it("selectCapability strips numeric prefix", async () => { const wrapper = await mountChat({ isOpen: true }); wrapper.vm.selectCapability("1. Create a SQL query for me"); expect(wrapper.vm.inputMessage.startsWith("Create")).toBe(true); }); }); // 12. 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 = ["
code
", '
b
', "
no change
"]; 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 = ''; const result = wrapper.vm.formatLogEntryContent(text); expect(result).not.toContain("