// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Written against the pre-extraction component: prompt history, auto navigation, scroll, image-chip Backspace. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { nextTick } from "vue"; import store from "@/test/unit/helpers/store"; import { computeUserOrgKey } from "@/utils/userOrgKey"; import i18n from "@/locales"; vi.mock("@/lib/core/Code/highlighter", () => ({ default: { highlight: vi.fn(() => ({ value: "" })), highlightAuto: vi.fn(() => ({ value: "" })), getLanguage: vi.fn(() => null), registerLanguage: vi.fn(), }, })); const { mockFetchAiChat, mockSaveToHistory } = vi.hoisted(() => ({ mockFetchAiChat: vi.fn(), mockSaveToHistory: vi.fn(), })); vi.mock("@/composables/useChatHistory", () => ({ useChatHistory: vi.fn(() => ({ saveToHistory: mockSaveToHistory, loadHistory: vi.fn().mockResolvedValue([]), loadChat: vi.fn().mockResolvedValue(null), deleteChatById: vi.fn().mockResolvedValue(true), clearAllHistory: vi.fn().mockResolvedValue(true), updateChatTitle: vi.fn().mockResolvedValue(true), })), })); vi.mock("@/composables/useAiChat", () => ({ default: vi.fn(() => ({ fetchAiChat: mockFetchAiChat, submitFeedback: vi.fn().mockResolvedValue(true), chatHistoryServer: vi.fn(() => ({ enabled: () => false })), registerAiChatHandler: vi.fn(), removeAiChatHandler: vi.fn(), getStructuredContext: vi.fn().mockResolvedValue(null), })), })); vi.mock("@/aws-exports", () => ({ default: { isEnterprise: "true", isCloud: "false" } })); vi.mock("vue-router", () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), currentRoute: { value: { path: "/" } }, })), useRoute: vi.fn(() => ({ fullPath: "/", path: "/", name: "home", query: {}, params: {} })), })); vi.mock("dompurify", () => ({ default: { sanitize: vi.fn((html: string) => html) } })); vi.mock("@/composables/contextProviders", () => ({ contextRegistry: { getActiveContext: vi.fn().mockResolvedValue(null) }, createDefaultContextProvider: vi.fn(), })); import O2AIChat from "./O2AIChat.vue"; // Both stores are scoped to the signed-in user and org, the same hash chat history uses. const scopedKey = async (prefix: string) => `${prefix}:${await computeUserOrgKey( store.state.userInfo.email ?? "", store.state.selectedOrganization?.identifier ?? "", )}`; // The key hash comes from WebCrypto, which resolves after flushPromises; awaiting the same digest lets the stores load. const settleScope = async () => { await flushPromises(); await scopedKey(""); await flushPromises(); }; let HISTORY_KEY = ""; let AUTO_NAV_KEY = ""; beforeEach(async () => { HISTORY_KEY = await scopedKey("ai-chat-query-history"); AUTO_NAV_KEY = await scopedKey("ai-chat-auto-navigation"); }); const stubs = { RichTextInput: { name: "RichTextInput", template: '
', props: ["modelValue", "placeholder", "disabled", "theme", "references", "borderless"], emits: ["update:modelValue", "keydown", "submit", "update:references"], methods: { setContent(text: string) { (this as any).lastSetContent = text; }, }, }, }; let wrapper: VueWrapper | null = null; const mountChat = async () => { wrapper = mount(O2AIChat, { global: { plugins: [store, i18n], stubs }, props: { isOpen: true, headerHeight: 0, aiChatInputContext: "", appendMode: true }, attachTo: document.body, }); await settleScope(); return wrapper.vm as any; }; const key = (k: string, target: EventTarget) => { const e = new KeyboardEvent("keydown", { key: k, cancelable: true }); Object.defineProperty(e, "target", { value: target }); return e; }; // The real composer is RichTextInput, a contenteditable div. A textarea never appears. const editableAt = (html: string, caretNode = 0, caretOffset?: number) => { const el = document.createElement("div"); el.setAttribute("contenteditable", "true"); Object.defineProperty(el, "isContentEditable", { value: true, configurable: true }); el.innerHTML = html; document.body.appendChild(el); const node = el.childNodes[caretNode] ?? el; const range = document.createRange(); range.setStart(node, caretOffset ?? (node.textContent?.length || 0)); range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); return el; }; const textareaAt = (value: string, cursor = value.length) => { const ta = document.createElement("textarea"); ta.value = value; document.body.appendChild(ta); ta.selectionStart = ta.selectionEnd = cursor; return ta; }; const chip = (text: string) => { const span = document.createElement("span"); span.className = "image-reference"; span.contentEditable = "false"; span.textContent = text; return span; }; const img = (filename: string) => ({ data: "x", mimeType: "image/png", filename, size: 1 }); const placeCaret = (node: Node, offset: number) => { const range = document.createRange(); range.setStart(node, offset); range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); }; beforeEach(() => { localStorage.clear(); mockFetchAiChat.mockReset().mockRejectedValue(new Error("offline")); mockSaveToHistory.mockReset().mockResolvedValue(42); store.state.currentChatTimestamp = null; vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { wrapper?.unmount(); wrapper = null; document.body.innerHTML = ""; vi.restoreAllMocks(); }); describe("prompt history (ArrowUp / ArrowDown)", () => { // Regression: one unscoped key meant the next user or org recalled the previous one's prompts. it("does not recall the previous org's prompts after switching organization", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["org A incident query"])); const vm = await mountChat(); const original = { ...store.state.selectedOrganization }; try { vm.handleKeyDown(key("ArrowUp", editableAt(""))); expect(vm.inputMessage).toBe("org A incident query"); store.dispatch("setSelectedOrganization", { ...original, identifier: "org-b" }); await settleScope(); vm.inputMessage = ""; vm.handleKeyDown(key("ArrowUp", editableAt(""))); expect(vm.inputMessage).toBe(""); store.dispatch("setSelectedOrganization", original); await settleScope(); vm.handleKeyDown(key("ArrowUp", editableAt(""))); expect(vm.inputMessage).toBe("org A incident query"); } finally { store.dispatch("setSelectedOrganization", original); await flushPromises(); } }); // Regression: the handler required target.tagName === "TEXTAREA", so recall was dead in the app. it("recalls prompts when the target is the contenteditable composer, not a textarea", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["newest", "older"])); const vm = await mountChat(); const editable = editableAt(""); expect(editable.tagName).not.toBe("TEXTAREA"); const up = key("ArrowUp", editable); vm.handleKeyDown(up); expect(up.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("newest"); vm.handleKeyDown(key("ArrowUp", editable)); expect(vm.inputMessage).toBe("older"); vm.handleKeyDown(key("ArrowDown", editable)); expect(vm.inputMessage).toBe("newest"); }); it("pushes the recalled prompt into the editor, which ignores modelValue while focused", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["recalled prompt"])); const vm = await mountChat(); const editable = editableAt(""); vm.handleKeyDown(key("ArrowUp", editable)); const input = wrapper!.findComponent({ name: "RichTextInput" }); expect((input.vm as any).lastSetContent).toBe("recalled prompt"); }); // setContent rewrites textContent, which destroys the reference chips and image spans in the composer. it("does not rewrite the composer when there is nothing to recall", async () => { localStorage.removeItem(HISTORY_KEY); const vm = await mountChat(); vm.handleKeyDown(key("ArrowUp", editableAt(""))); const input = wrapper!.findComponent({ name: "RichTextInput" }); expect((input.vm as any).lastSetContent).toBeUndefined(); }); it("does not rewrite the composer on an ArrowUp past the oldest prompt", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["only one"])); const vm = await mountChat(); const editable = editableAt(""); const input = wrapper!.findComponent({ name: "RichTextInput" }); vm.handleKeyDown(key("ArrowUp", editable)); (input.vm as any).lastSetContent = undefined; vm.handleKeyDown(key("ArrowUp", editable)); expect((input.vm as any).lastSetContent).toBeUndefined(); }); // The chips are gone from the rewritten composer, so keeping them would attach files the user cannot see. it("drops reference chips when a recalled prompt replaces them", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["recalled prompt"])); const vm = await mountChat(); vm.contextReferences = [{ name: "app.log", fullContent: "x" }]; vm.handleKeyDown(key("ArrowUp", editableAt(""))); expect(vm.contextReferences).toEqual([]); }); it("leaves the caret alone when it is below the first line of the composer", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["newest"])); const vm = await mountChat(); // caret inside "two", after the
const editable = editableAt("one
two", 2); const up = key("ArrowUp", editable); vm.handleKeyDown(up); expect(up.defaultPrevented).toBe(false); expect(vm.inputMessage).toBe(""); }); it("recalls stored prompts newest-first and walks back down to an empty input", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["newest", "older", "oldest"])); const vm = await mountChat(); const ta = textareaAt(""); const up1 = key("ArrowUp", ta); vm.handleKeyDown(up1); expect(up1.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("newest"); vm.handleKeyDown(key("ArrowUp", ta)); vm.handleKeyDown(key("ArrowUp", ta)); expect(vm.inputMessage).toBe("oldest"); const beyond = key("ArrowUp", ta); vm.handleKeyDown(beyond); expect(beyond.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("oldest"); vm.handleKeyDown(key("ArrowDown", ta)); expect(vm.inputMessage).toBe("older"); vm.handleKeyDown(key("ArrowDown", ta)); expect(vm.inputMessage).toBe("newest"); const toEmpty = key("ArrowDown", ta); vm.handleKeyDown(toEmpty); expect(toEmpty.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe(""); vm.inputMessage = "draft"; const idle = key("ArrowDown", ta); vm.handleKeyDown(idle); expect(idle.defaultPrevented).toBe(false); expect(vm.inputMessage).toBe("draft"); }); it("ignores ArrowUp when the caret is below the first line or the target is not a textarea", async () => { localStorage.setItem(HISTORY_KEY, JSON.stringify(["prev"])); const vm = await mountChat(); vm.inputMessage = "draft"; const below = key("ArrowUp", textareaAt("line1\nline2")); vm.handleKeyDown(below); expect(below.defaultPrevented).toBe(false); const div = document.createElement("div"); const onDiv = key("ArrowUp", div); vm.handleKeyDown(onDiv); expect(onDiv.defaultPrevented).toBe(false); expect(vm.inputMessage).toBe("draft"); const firstLine = key("ArrowUp", textareaAt("line1\nline2", 3)); vm.handleKeyDown(firstLine); expect(firstLine.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("prev"); }); it("prevents ArrowUp on the first line even with an empty history, leaving ArrowDown inert", async () => { const vm = await mountChat(); vm.inputMessage = "draft"; const ta = textareaAt("draft"); const up = key("ArrowUp", ta); vm.handleKeyDown(up); expect(up.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("draft"); const down = key("ArrowDown", ta); vm.handleKeyDown(down); expect(down.defaultPrevented).toBe(false); }); it("treats corrupt stored history as empty", async () => { localStorage.setItem(HISTORY_KEY, "{not json"); const vm = await mountChat(); vm.inputMessage = "draft"; vm.handleKeyDown(key("ArrowUp", textareaAt(""))); expect(vm.inputMessage).toBe("draft"); expect(console.error).toHaveBeenCalledWith("Error loading query history:", expect.anything()); }); it("records a sent prompt trimmed, deduplicated to the front, capped at 10, and resets the cursor", async () => { const stored = Array.from({ length: 10 }, (_, i) => `q${i}`); localStorage.setItem(HISTORY_KEY, JSON.stringify(stored)); const vm = await mountChat(); const ta = textareaAt(""); vm.handleKeyDown(key("ArrowUp", ta)); vm.inputMessage = " q5 "; await vm.sendMessage(); await flushPromises(); const after = JSON.parse(localStorage.getItem(HISTORY_KEY)!); expect(after).toEqual(["q5", "q0", "q1", "q2", "q3", "q4", "q6", "q7", "q8", "q9"]); const down = key("ArrowDown", ta); vm.handleKeyDown(down); expect(down.defaultPrevented).toBe(false); // A rejected fetchAiChat still runs teardown, so the next send is not blocked. expect(vm.isLoading).toBe(false); vm.inputMessage = "fresh"; await vm.sendMessage(); await flushPromises(); const capped = JSON.parse(localStorage.getItem(HISTORY_KEY)!); expect(capped).toHaveLength(10); expect(capped[0]).toBe("fresh"); expect(capped).not.toContain("q9"); }); it("does not record an images-only send", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; vm.inputMessage = " "; await vm.sendMessage(); await flushPromises(); expect(localStorage.getItem(HISTORY_KEY)).toBeNull(); }); }); describe("auto navigation preferences", () => { it("holds a pending value for a new chat without persisting it", async () => { const vm = await mountChat(); expect(vm.isAutoNavigationEnabled).toBe(true); vm.isAutoNavigationEnabled = false; expect(vm.isAutoNavigationEnabled).toBe(false); expect(localStorage.getItem(AUTO_NAV_KEY)).toBeNull(); }); it("defaults an unknown chat to on and persists a per-chat choice", async () => { const vm = await mountChat(); vm.currentChatId = 5; expect(vm.isAutoNavigationEnabled).toBe(true); vm.isAutoNavigationEnabled = false; expect(vm.isAutoNavigationEnabled).toBe(false); expect(localStorage.getItem(AUTO_NAV_KEY)).toBe('{"5":false}'); }); it("loads stored preferences with numeric chat ids", async () => { localStorage.setItem(AUTO_NAV_KEY, '{"7":false,"8":true}'); const vm = await mountChat(); vm.currentChatId = 7; expect(vm.isAutoNavigationEnabled).toBe(false); vm.currentChatId = 8; expect(vm.isAutoNavigationEnabled).toBe(true); }); it("recovers from corrupt stored preferences", async () => { localStorage.setItem(AUTO_NAV_KEY, "{bad"); const vm = await mountChat(); vm.currentChatId = 7; expect(vm.isAutoNavigationEnabled).toBe(true); expect(console.error).toHaveBeenCalledWith( "Error loading auto navigation preferences:", expect.anything(), ); }); it("carries the pending choice onto the id minted by the first save", async () => { const vm = await mountChat(); vm.isAutoNavigationEnabled = false; vm.inputMessage = "hello"; await vm.sendMessage(); await flushPromises(); expect(vm.currentChatId).toBe(42); expect(vm.isAutoNavigationEnabled).toBe(false); expect(JSON.parse(localStorage.getItem(AUTO_NAV_KEY)!)).toEqual({ "42": false }); }); it("resets the pending choice to on for a new chat", async () => { const vm = await mountChat(); vm.isAutoNavigationEnabled = false; vm.addNewChat(); expect(vm.isAutoNavigationEnabled).toBe(true); }); }); describe("scroll state", () => { const metrics = (el: HTMLElement, m: { top: number; height: number; client: number }) => { Object.defineProperty(el, "scrollHeight", { configurable: true, value: m.height }); Object.defineProperty(el, "clientHeight", { configurable: true, value: m.client }); el.scrollTop = m.top; Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: m.top }); }; it("uses a fixed 50 threshold for auto-scroll and 100 for the jump button", async () => { const vm = await mountChat(); const el = vm.messagesContainer as HTMLElement; expect(vm.getScrollThreshold()).toBe(50); metrics(el, { top: 450, height: 1000, client: 500 }); vm.checkIfShouldAutoScroll(); expect(vm.shouldAutoScroll).toBe(true); expect(vm.showScrollToBottom).toBe(false); metrics(el, { top: 449, height: 1000, client: 500 }); vm.checkIfShouldAutoScroll(); expect(vm.shouldAutoScroll).toBe(false); expect(vm.showScrollToBottom).toBe(false); metrics(el, { top: 399, height: 1000, client: 500 }); vm.checkIfShouldAutoScroll(); expect(vm.showScrollToBottom).toBe(true); metrics(el, { top: 0, height: 600, client: 500 }); vm.checkIfShouldAutoScroll(); expect(vm.showScrollToBottom).toBe(false); }); it("smooth-scrolls on demand and re-arms auto-scroll", async () => { const vm = await mountChat(); const el = vm.messagesContainer as HTMLElement; metrics(el, { top: 0, height: 900, client: 300 }); vm.checkIfShouldAutoScroll(); expect(vm.showScrollToBottom).toBe(true); const scrollTo = vi.fn(); (el as any).scrollTo = scrollTo; const pending = vm.scrollToBottomSmooth(); expect(scrollTo).not.toHaveBeenCalled(); await pending; expect(scrollTo).toHaveBeenCalledWith({ top: 900, behavior: "smooth" }); expect(vm.showScrollToBottom).toBe(false); expect(vm.shouldAutoScroll).toBe(true); }); it("scrolls to the bottom when a message is sent", async () => { const vm = await mountChat(); const el = vm.messagesContainer as HTMLElement; metrics(el, { top: 0, height: 900, client: 300 }); vm.checkIfShouldAutoScroll(); expect(vm.shouldAutoScroll).toBe(false); vm.inputMessage = "hello"; await vm.sendMessage(); await flushPromises(); expect(el.scrollTop).toBe(900); expect(vm.shouldAutoScroll).toBe(true); }); it("scrolls a loading-indicator element into view only after a tick", async () => { const vm = await mountChat(); const indicator = document.createElement("div"); indicator.id = "loading-indicator"; indicator.scrollIntoView = vi.fn(); document.body.appendChild(indicator); const pending = vm.scrollToLoadingIndicator(); expect(indicator.scrollIntoView).not.toHaveBeenCalled(); await pending; expect(indicator.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "end" }); }); }); describe("Backspace on an image reference", () => { const editable = () => { const host = document.createElement("div"); host.setAttribute("contenteditable", "true"); document.body.appendChild(host); return host; }; it("removes a chip before a text caret at offset 0, its pending image, and fires a bubbling input", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png"), img("b.png")]; const host = editable(); host.append(chip("@[a.png]")); const text = document.createTextNode(" after"); host.append(text); placeCaret(text, 0); const onInput = vi.fn(); document.body.addEventListener("input", onInput); const e = key("Backspace", text.parentElement!); expect(vm.handleKeyDown(e)).toBeUndefined(); expect(e.defaultPrevented).toBe(true); expect(host.querySelector(".image-reference")).toBeNull(); expect(vm.pendingImages.map((i: any) => i.filename)).toEqual(["b.png"]); expect(onInput).toHaveBeenCalledTimes(1); expect(onInput.mock.calls[0][0].target).toBe(host); }); it("removes a chip immediately before an element caret, found through a wrapper target", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; const wrap = document.createElement("div"); document.body.appendChild(wrap); const host = document.createElement("div"); host.setAttribute("contenteditable", "true"); wrap.appendChild(host); host.append(document.createTextNode("x"), chip("@[a.png]")); placeCaret(host, 2); const e = key("Backspace", wrap); vm.handleKeyDown(e); expect(e.defaultPrevented).toBe(true); expect(host.childNodes).toHaveLength(1); expect(vm.pendingImages).toHaveLength(0); }); it("removes a chip whose text lacks the @[name] form but keeps every pending image", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; const host = editable(); host.append(chip("🖼️a.png×")); placeCaret(host, 1); const e = key("Backspace", host); vm.handleKeyDown(e); expect(e.defaultPrevented).toBe(true); expect(host.querySelector(".image-reference")).toBeNull(); expect(vm.pendingImages).toHaveLength(1); }); it("does nothing when the caret is not directly after a chip", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; const host = editable(); const text = document.createTextNode("abc"); host.append(chip("@[a.png]"), text); placeCaret(text, 1); const mid = key("Backspace", host); vm.handleKeyDown(mid); expect(mid.defaultPrevented).toBe(false); placeCaret(host, 0); const start = key("Backspace", host); vm.handleKeyDown(start); expect(start.defaultPrevented).toBe(false); expect(host.querySelector(".image-reference")).not.toBeNull(); expect(vm.pendingImages).toHaveLength(1); }); it("returns without touching state when there is no selection range", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; const host = editable(); host.append(chip("@[a.png]")); window.getSelection()!.removeAllRanges(); const e = key("Backspace", host); expect(vm.handleKeyDown(e)).toBeUndefined(); expect(e.defaultPrevented).toBe(false); expect(host.querySelector(".image-reference")).not.toBeNull(); }); it("deletes a trailing @[name] from the textarea model and restores the caret on the next tick", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; vm.inputMessage = "see @[a.png]"; const ta = textareaAt("see @[a.png]"); const e = key("Backspace", ta); vm.handleKeyDown(e); expect(e.defaultPrevented).toBe(true); expect(vm.inputMessage).toBe("see "); expect(vm.pendingImages).toHaveLength(0); expect(ta.selectionStart).toBe(12); await nextTick(); expect(ta.selectionStart).toBe(4); expect(ta.selectionEnd).toBe(4); }); it("keeps the textarea text when the caret is not at the end of a reference", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; vm.inputMessage = "see @[a.png] now"; const e = key("Backspace", textareaAt("see @[a.png] now")); vm.handleKeyDown(e); expect(e.defaultPrevented).toBe(false); expect(vm.inputMessage).toBe("see @[a.png] now"); expect(vm.pendingImages).toHaveLength(1); }); it("deletes an unknown reference from the text without touching pending images", async () => { const vm = await mountChat(); vm.pendingImages = [img("a.png")]; vm.inputMessage = "@[zzz.png]"; vm.handleKeyDown(key("Backspace", textareaAt("@[zzz.png]"))); expect(vm.inputMessage).toBe(""); expect(vm.pendingImages).toHaveLength(1); }); });