// 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 . import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mount, VueWrapper, flushPromises } from "@vue/test-utils"; import { nextTick } from "vue"; import store from "@/test/unit/helpers/store"; import i18n from "@/locales"; // ── Module mocks (hoisted) ─────────────────────────────────────────────────── vi.mock("highlight.js", () => ({ default: { highlight: vi.fn(() => ({ value: "" })), highlightAuto: vi.fn(() => ({ value: "" })), getLanguage: vi.fn(() => null), registerLanguage: vi.fn(), }, })); vi.mock("marked", () => ({ marked: { setOptions: vi.fn(), parse: vi.fn((content: string) => content), lexer: vi.fn(() => []), parser: vi.fn(() => ""), }, })); const mockSaveToHistory = vi.fn().mockResolvedValue(42); const mockLoadHistory = vi.fn().mockResolvedValue([]); const mockLoadChat = vi.fn().mockResolvedValue(null); const mockDeleteChatById = vi.fn().mockResolvedValue(true); const mockClearAllHistory = vi.fn().mockResolvedValue(true); const mockUpdateChatTitle = vi.fn().mockResolvedValue(true); vi.mock("@/composables/useChatHistory", () => ({ useChatHistory: vi.fn(() => ({ saveToHistory: mockSaveToHistory, loadHistory: mockLoadHistory, loadChat: mockLoadChat, deleteChatById: mockDeleteChatById, clearAllHistory: mockClearAllHistory, updateChatTitle: mockUpdateChatTitle, })), })); // vi.hoisted, not a plain const: O2AIChat.vue destructures useAiChat() at module // scope, so the factory runs during import. const { mockFetchAiChat } = vi.hoisted(() => ({ mockFetchAiChat: vi.fn() })); vi.mock("@/composables/useAiChat", () => ({ default: vi.fn(() => ({ fetchAiChat: mockFetchAiChat, submitFeedback: vi.fn().mockResolvedValue(true), registerAiChatHandler: vi.fn(), removeAiChatHandler: vi.fn(), getStructuredContext: vi.fn().mockResolvedValue(null), })), })); vi.mock("@/utils/zincutils", async (importOriginal) => { const actual = (await importOriginal()) as any; return { ...actual, getImageURL: vi.fn((path: string) => `/mocked/${path}`), getUUIDv7: vi.fn(() => "mock-uuid-v7"), generateTraceContext: vi.fn(() => ({ traceId: "mock-trace" })), }; }); 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: "/" } }, })), // The component watches route.fullPath to hand a live stream off to the // sidebar instance on navigation, so this mock must expose fullPath. 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(), })); // Component import must come after all vi.mock() declarations. import O2AIChat from "./O2AIChat.vue"; import { getManager, resetManager } from "@/lib/vue-shortcut-manager"; // ── Stub definitions ───────────────────────────────────────────────────────── const stubs = { RichTextInput: { template: '
', props: ["modelValue", "placeholder", "disabled", "theme", "references", "borderless"], emits: ["update:modelValue", "keydown", "submit", "update:references"], }, ConfirmDialog: { template: '
', props: ["modelValue", "title", "message"], emits: ["update:ok", "update:cancel", "update:modelValue"], }, O2AIConfirmDialog: { template: '
', props: ["visible", "confirmation"], emits: ["confirm", "cancel", "always-confirm"], }, // Stub ODialog with the same surface as the real component so the parent's // v-model:open binding, slot content, and click:primary/secondary emits all // exercise the migrated path. ODialog: { name: "ODialog", template: '
' + '
{{ title }}
' + '
' + '' + '' + '' + "
", props: [ "open", "title", "subTitle", "size", "width", "persistent", "showClose", "primaryButtonLabel", "secondaryButtonLabel", "neutralButtonLabel", "primaryButtonVariant", "secondaryButtonVariant", "neutralButtonVariant", "primaryButtonDisabled", "secondaryButtonDisabled", "neutralButtonDisabled", "primaryButtonLoading", "secondaryButtonLoading", "neutralButtonLoading", ], emits: ["update:open", "click:primary", "click:secondary", "click:neutral"], }, // Stub ODrawer with the same surface (default slot, v-model:open) so the // History drawer can be asserted without pulling in the real component. ODrawer: { name: "ODrawer", template: '
' + '
{{ title }}
' + '
' + '' + "
", props: [ "open", "title", "subTitle", "size", "width", "persistent", "showClose", "primaryButtonLabel", "secondaryButtonLabel", "neutralButtonLabel", ], emits: ["update:open", "click:primary", "click:secondary", "click:neutral"], }, }; function mountO2AIChat(props: Record = {}) { return mount(O2AIChat, { global: { plugins: [store, i18n], stubs, }, props: { isOpen: false, headerHeight: 0, aiChatInputContext: "", appendMode: true, ...props, }, }); } // ── Tests ──────────────────────────────────────────────────────────────────── describe("O2AIChat", () => { let wrapper: VueWrapper; afterEach(() => { wrapper?.unmount(); vi.clearAllMocks(); }); describe("initial render", () => { it("should render without errors when isOpen is false", () => { wrapper = mountO2AIChat({ isOpen: false }); expect(wrapper.exists()).toBe(true); }); it("should render the chat content wrapper when isOpen is true", async () => { wrapper = mountO2AIChat({ isOpen: true }); await flushPromises(); const chatContent = wrapper.find(".chat-content-wrapper"); expect(chatContent.exists()).toBe(true); }); it("should show empty state when no chat messages are present", async () => { wrapper = mountO2AIChat({ isOpen: true }); await flushPromises(); const welcomeSection = wrapper.find(".welcome-section"); expect(welcomeSection.exists()).toBe(true); }); }); describe("Ctrl+B ownership", () => { beforeEach(() => resetManager()); afterEach(() => resetManager()); // A second ctrl+b here overwrites MainLayout's gated aiChatToggle (same key + scope). it("should not register ctrl+b, leaving the gated global binding in place", () => { wrapper = mountO2AIChat({ isOpen: false }); const keys = getManager()! .getAll() .map((s) => s.key); expect(keys).not.toContain("ctrl+b"); expect(keys).not.toContain("meta+b"); expect(keys).toContain("escape"); }); }); describe("addNewChat", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: true }); }); it("should reset chatMessages when addNewChat is called", async () => { // Seed some messages first so we can verify they are cleared. (wrapper.vm as any).chatMessages = [ { role: "user", content: "hello" }, { role: "assistant", content: "hi" }, ]; await nextTick(); (wrapper.vm as any).addNewChat(); await nextTick(); expect((wrapper.vm as any).chatMessages).toHaveLength(0); }); it("should reset currentChatId to null when addNewChat is called", async () => { (wrapper.vm as any).currentChatId = 99; (wrapper.vm as any).addNewChat(); await nextTick(); expect((wrapper.vm as any).currentChatId).toBeNull(); }); it("should reset displayedTitle to empty string when addNewChat is called", async () => { (wrapper.vm as any).displayedTitle = "My old chat"; (wrapper.vm as any).addNewChat(); await nextTick(); expect((wrapper.vm as any).displayedTitle).toBe(""); }); }); describe("loadHistory", () => { it("should call dbLoadHistory and populate chatHistory", async () => { const mockEntries = [ { id: 1, title: "First chat", timestamp: new Date().toISOString(), model: "gpt-4", messages: [], sessionId: "s1", userOrgKey: "key", }, { id: 2, title: "Second chat", timestamp: new Date().toISOString(), model: "gpt-4", messages: [], sessionId: "s2", userOrgKey: "key", }, ]; mockLoadHistory.mockResolvedValueOnce(mockEntries); wrapper = mountO2AIChat({ isOpen: true }); await flushPromises(); // loadHistory is called on mount when isOpen=true; reset and call again mockLoadHistory.mockClear(); mockLoadHistory.mockResolvedValueOnce(mockEntries); await (wrapper.vm as any).loadHistory(); await flushPromises(); expect(mockLoadHistory).toHaveBeenCalledTimes(1); expect((wrapper.vm as any).chatHistory).toHaveLength(2); expect((wrapper.vm as any).chatHistory[0].title).toBe("First chat"); }); }); describe("filteredChatHistory", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: false }); }); it("should return all history when historySearchTerm is empty", async () => { (wrapper.vm as any).chatHistory = [ { id: 1, title: "SQL query help" }, { id: 2, title: "Log analysis" }, ]; (wrapper.vm as any).historySearchTerm = ""; await nextTick(); expect((wrapper.vm as any).filteredChatHistory).toHaveLength(2); }); it("should filter history case-insensitively by historySearchTerm", async () => { (wrapper.vm as any).chatHistory = [ { id: 1, title: "SQL query help" }, { id: 2, title: "Log analysis" }, { id: 3, title: "sql performance tips" }, ]; (wrapper.vm as any).historySearchTerm = "sql"; await nextTick(); const result = (wrapper.vm as any).filteredChatHistory; expect(result).toHaveLength(2); expect(result.map((c: any) => c.id)).toEqual([1, 3]); }); it("should return empty array when no chats match the search term", async () => { (wrapper.vm as any).chatHistory = [ { id: 1, title: "SQL query help" }, { id: 2, title: "Log analysis" }, ]; (wrapper.vm as any).historySearchTerm = "dashboard"; await nextTick(); expect((wrapper.vm as any).filteredChatHistory).toHaveLength(0); }); }); describe("deleteChat", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: false }); }); it("should set chatToDelete and show confirm dialog when deleteChat is called", async () => { (wrapper.vm as any).deleteChat(42); await nextTick(); expect((wrapper.vm as any).chatToDelete).toBe(42); expect((wrapper.vm as any).showDeleteChatConfirmDialog).toBe(true); }); }); describe("confirmDeleteChat", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: false }); }); it("should call dbDeleteChatById with the chatToDelete id", async () => { (wrapper.vm as any).chatToDelete = 7; mockDeleteChatById.mockResolvedValueOnce(true); await (wrapper.vm as any).confirmDeleteChat(); await flushPromises(); expect(mockDeleteChatById).toHaveBeenCalledWith(7); }); it("should call addNewChat when the deleted chat is the current chat", async () => { (wrapper.vm as any).currentChatId = 7; (wrapper.vm as any).chatToDelete = 7; (wrapper.vm as any).chatMessages = [{ role: "user", content: "test" }]; mockDeleteChatById.mockResolvedValueOnce(true); await (wrapper.vm as any).confirmDeleteChat(); await flushPromises(); expect((wrapper.vm as any).chatMessages).toHaveLength(0); expect((wrapper.vm as any).currentChatId).toBeNull(); }); it("should not reset currentChatId when a different chat is deleted", async () => { (wrapper.vm as any).currentChatId = 10; (wrapper.vm as any).chatToDelete = 7; mockDeleteChatById.mockResolvedValueOnce(true); await (wrapper.vm as any).confirmDeleteChat(); await flushPromises(); expect((wrapper.vm as any).currentChatId).toBe(10); }); it("should reset chatToDelete and hide confirm dialog after deletion", async () => { (wrapper.vm as any).chatToDelete = 7; (wrapper.vm as any).showDeleteChatConfirmDialog = true; mockDeleteChatById.mockResolvedValueOnce(true); await (wrapper.vm as any).confirmDeleteChat(); await flushPromises(); expect((wrapper.vm as any).chatToDelete).toBeNull(); expect((wrapper.vm as any).showDeleteChatConfirmDialog).toBe(false); }); }); describe("clearAllConversations", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: false }); }); it("should set showClearAllConfirmDialog to true when clearAllConversations is called", async () => { (wrapper.vm as any).showClearAllConfirmDialog = false; (wrapper.vm as any).clearAllConversations(); await nextTick(); expect((wrapper.vm as any).showClearAllConfirmDialog).toBe(true); }); }); describe("saveEditedTitle", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: false }); }); it("should call dbUpdateChatTitle when currentChatId and editingTitle are set", async () => { (wrapper.vm as any).currentChatId = 5; (wrapper.vm as any).editingTitle = "My new title"; mockUpdateChatTitle.mockResolvedValueOnce(true); await (wrapper.vm as any).saveEditedTitle(); await flushPromises(); expect(mockUpdateChatTitle).toHaveBeenCalledWith(5, "My new title"); }); it("should update displayedTitle after successful title save", async () => { (wrapper.vm as any).currentChatId = 5; (wrapper.vm as any).editingTitle = "Updated Title"; (wrapper.vm as any).displayedTitle = "Old Title"; mockUpdateChatTitle.mockResolvedValueOnce(true); await (wrapper.vm as any).saveEditedTitle(); await flushPromises(); expect((wrapper.vm as any).displayedTitle).toBe("Updated Title"); }); it("should not call dbUpdateChatTitle when editingTitle is empty", async () => { (wrapper.vm as any).currentChatId = 5; (wrapper.vm as any).editingTitle = ""; await (wrapper.vm as any).saveEditedTitle(); await flushPromises(); expect(mockUpdateChatTitle).not.toHaveBeenCalled(); }); it("should not call dbUpdateChatTitle when currentChatId is null", async () => { (wrapper.vm as any).currentChatId = null; (wrapper.vm as any).editingTitle = "Some title"; await (wrapper.vm as any).saveEditedTitle(); await flushPromises(); expect(mockUpdateChatTitle).not.toHaveBeenCalled(); }); }); // ── Migration: ODrawer (Chat History) ──────────────────────────────────── describe("history drawer (ODrawer)", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: true }); }); it("should not render the ODrawer when showHistory is false", async () => { (wrapper.vm as any).showHistory = false; await nextTick(); expect(wrapper.find('[data-test="o-drawer"]').exists()).toBe(false); }); it("should render the ODrawer with the Chat History title when showHistory is true", async () => { (wrapper.vm as any).showHistory = true; await nextTick(); const drawer = wrapper.findComponent({ name: "ODrawer" }); expect(drawer.exists()).toBe(true); expect(drawer.props("open")).toBe(true); expect(drawer.props("title")).toBe("Chat History"); expect(drawer.props("size")).toBe("sm"); expect(wrapper.find('[data-test="o-drawer-title"]').text()).toBe("Chat History"); }); it("should close the drawer when update:open is emitted with false", async () => { (wrapper.vm as any).showHistory = true; await nextTick(); const drawer = wrapper.findComponent({ name: "ODrawer" }); drawer.vm.$emit("update:open", false); await nextTick(); expect((wrapper.vm as any).showHistory).toBe(false); }); it("should render the chat history items inside the drawer slot", async () => { (wrapper.vm as any).chatHistory = [ { id: 1, title: "Alpha", timestamp: Date.now(), model: "gpt-4" }, { id: 2, title: "Beta", timestamp: Date.now(), model: "gpt-4" }, ]; (wrapper.vm as any).showHistory = true; await nextTick(); const body = wrapper.find('[data-test="o-drawer-body"]'); expect(body.exists()).toBe(true); expect(body.text()).toContain("Alpha"); expect(body.text()).toContain("Beta"); }); }); // ── Migration: ODialog (Edit Title) ────────────────────────────────────── describe("edit title dialog (ODialog)", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: true }); }); function findEditTitleDialog() { return wrapper .findAllComponents({ name: "ODialog" }) .find((d) => d.props("title") === "Edit Chat Title"); } it("should not render the edit title dialog when showEditTitleDialog is false", async () => { (wrapper.vm as any).showEditTitleDialog = false; await nextTick(); const dialog = findEditTitleDialog(); // ODialog stub is always in the tree but only renders its body when open=true. expect(dialog?.props("open")).toBe(false); expect(wrapper.find('[data-test="o-dialog"]').exists()).toBe(false); }); it("should render the edit title dialog with primary/secondary button labels", async () => { (wrapper.vm as any).showEditTitleDialog = true; await nextTick(); const dialog = findEditTitleDialog(); expect(dialog).toBeDefined(); expect(dialog!.props("primaryButtonLabel")).toBe("Save"); expect(dialog!.props("secondaryButtonLabel")).toBe("Cancel"); expect(dialog!.props("size")).toBe("sm"); }); it("should call saveEditedTitle and update title when click:primary is emitted", async () => { (wrapper.vm as any).currentChatId = 11; (wrapper.vm as any).editingTitle = "Brand New Title"; (wrapper.vm as any).showEditTitleDialog = true; mockUpdateChatTitle.mockResolvedValueOnce(true); await nextTick(); const dialog = findEditTitleDialog(); expect(dialog).toBeDefined(); dialog!.vm.$emit("click:primary"); await flushPromises(); expect(mockUpdateChatTitle).toHaveBeenCalledWith(11, "Brand New Title"); expect((wrapper.vm as any).showEditTitleDialog).toBe(false); }); it("should close the dialog without calling updateChatTitle when click:secondary is emitted", async () => { (wrapper.vm as any).currentChatId = 11; (wrapper.vm as any).editingTitle = "Whatever"; (wrapper.vm as any).showEditTitleDialog = true; await nextTick(); const dialog = findEditTitleDialog(); expect(dialog).toBeDefined(); dialog!.vm.$emit("click:secondary"); await nextTick(); expect((wrapper.vm as any).showEditTitleDialog).toBe(false); expect(mockUpdateChatTitle).not.toHaveBeenCalled(); }); it("should close the dialog when update:open is emitted with false", async () => { (wrapper.vm as any).showEditTitleDialog = true; await nextTick(); const dialog = findEditTitleDialog(); dialog!.vm.$emit("update:open", false); await nextTick(); expect((wrapper.vm as any).showEditTitleDialog).toBe(false); }); }); // ── Migration: ODialog (Image Preview) ─────────────────────────────────── describe("image preview dialog (ODialog)", () => { beforeEach(() => { wrapper = mountO2AIChat({ isOpen: true }); }); function findImagePreviewDialog() { // The image preview dialog has no primary/secondary labels. return wrapper .findAllComponents({ name: "ODialog" }) .find((d) => d.props("size") === "lg" && d.props("primaryButtonLabel") === undefined); } it("should not render the image preview dialog when showImagePreview is false", async () => { (wrapper.vm as any).showImagePreview = false; await nextTick(); const dialog = findImagePreviewDialog(); // ODialog stub is always in the tree but only renders its body when open=true. expect(dialog?.props("open")).toBe(false); }); it("should render the dialog with previewImage filename as the title", async () => { (wrapper.vm as any).previewImage = { filename: "screenshot.png", mimeType: "image/png", data: "AAAA", }; (wrapper.vm as any).showImagePreview = true; await nextTick(); const dialog = findImagePreviewDialog(); expect(dialog).toBeDefined(); expect(dialog!.props("title")).toBe("screenshot.png"); expect(dialog!.props("size")).toBe("lg"); }); it("should clear preview state when update:open is emitted with false", async () => { (wrapper.vm as any).previewImage = { filename: "foo.png", mimeType: "image/png", data: "ZZ", }; (wrapper.vm as any).showImagePreview = true; await nextTick(); const dialog = findImagePreviewDialog(); expect(dialog).toBeDefined(); dialog!.vm.$emit("update:open", false); await nextTick(); // closeImagePreview should have run expect((wrapper.vm as any).showImagePreview).toBe(false); expect((wrapper.vm as any).previewImage).toBeNull(); }); it("should render the image element inside the dialog body when previewImage is set", async () => { (wrapper.vm as any).previewImage = { filename: "diag.jpg", mimeType: "image/jpeg", data: "BASE64", }; (wrapper.vm as any).showImagePreview = true; await nextTick(); const img = wrapper.find('[data-test="o-dialog-body"] img'); expect(img.exists()).toBe(true); expect(img.attributes("src")).toBe("data:image/jpeg;base64,BASE64"); expect(img.attributes("alt")).toBe("diag.jpg"); }); }); describe("org-switch watcher", () => { it("should call loadHistory when selectedOrganization.identifier changes and isOpen is true", async () => { wrapper = mountO2AIChat({ isOpen: true }); await flushPromises(); mockLoadHistory.mockClear(); mockLoadHistory.mockResolvedValue([]); // Simulate an org switch via the store mutation store.commit("setSelectedOrganization", { label: "New Org", id: 999, identifier: "new-org", user_email: "example@gmail.com", subscription_type: "", }); await nextTick(); await flushPromises(); expect(mockLoadHistory).toHaveBeenCalled(); }); it("should reset chatMessages on org switch regardless of isOpen", async () => { wrapper = mountO2AIChat({ isOpen: false }); await flushPromises(); (wrapper.vm as any).chatMessages = [{ role: "user", content: "test" }]; await nextTick(); store.commit("setSelectedOrganization", { label: "Another Org", id: 888, identifier: "another-org", user_email: "example@gmail.com", subscription_type: "", }); await nextTick(); await flushPromises(); expect((wrapper.vm as any).chatMessages).toHaveLength(0); // Restore original org for subsequent tests store.commit("setSelectedOrganization", { label: "default Organization", id: 159, identifier: "default", user_email: "example@gmail.com", subscription_type: "", }); }); }); describe("session restore (HA)", () => { // A session lives on one o2-ai replica. When that replica can no longer serve // it, the UI restores the transcript into a fresh session. describe("isSessionOwnerUnavailable", () => { it("should detect the code nested under detail (FastAPI shape)", () => { wrapper = mountO2AIChat({ isOpen: true }); const fn = (wrapper.vm as any).isSessionOwnerUnavailable; expect(fn({ detail: { code: "session_owner_unavailable" } })).toBe(true); }); it("should detect the code at the top level", () => { wrapper = mountO2AIChat({ isOpen: true }); const fn = (wrapper.vm as any).isSessionOwnerUnavailable; expect(fn({ code: "session_owner_unavailable" })).toBe(true); }); it("should NOT treat other failures as a lost session", () => { // Restoring abandons the current session, so anything short of the // explicit server code must not trigger it. wrapper = mountO2AIChat({ isOpen: true }); const fn = (wrapper.vm as any).isSessionOwnerUnavailable; expect(fn({ detail: { code: "some_other_error" } })).toBe(false); expect(fn({ message: "Server error (503)" })).toBe(false); expect(fn({})).toBe(false); expect(fn(null)).toBe(false); expect(fn(undefined)).toBe(false); }); }); describe("sendConfirmation", () => { it("should report success when the confirmation is accepted", async () => { wrapper = mountO2AIChat({ isOpen: true }); global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); const ok = await (wrapper.vm as any).sendConfirmation("sess-1", true); expect(ok).toBe(true); expect((wrapper.vm as any).chatMessages).toHaveLength(0); }); it("should surface a 404 instead of silently reporting success", async () => { // The bug the .ok check exists for: a dropped confirmation looked // identical to an accepted one while the agent auto-denied. wrapper = mountO2AIChat({ isOpen: true }); global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); const ok = await (wrapper.vm as any).sendConfirmation("sess-1", true); expect(ok).toBe(false); const msgs = (wrapper.vm as any).chatMessages; expect(msgs.length).toBeGreaterThan(0); expect(msgs[msgs.length - 1].contentBlocks[0].type).toBe("error"); }); it("should surface a network failure", async () => { wrapper = mountO2AIChat({ isOpen: true }); global.fetch = vi.fn().mockRejectedValue(new Error("network down")); const ok = await (wrapper.vm as any).sendConfirmation("sess-1", false); expect(ok).toBe(false); expect((wrapper.vm as any).chatMessages.length).toBeGreaterThan(0); }); }); describe("streamOwnerUnavailable lifecycle", () => { // A turn that throws or is aborted skips the clear at the end of the try // block, so sendMessage clears the flag on entry instead. it("should clear a stale flag on entry, even when the turn fails early", async () => { wrapper = mountO2AIChat({ isOpen: true }); const vm = wrapper.vm as any; // Left over from a previous turn that threw after an owner-unavailable. vm.streamOwnerUnavailable = true; // Fail before the stream opens — fetchAiChat returns null on a network // error, the earliest realistic exit. mockFetchAiChat.mockResolvedValueOnce(null); vm.inputMessage = "how many errors today"; await vm.sendMessage(); await flushPromises(); expect(vm.streamOwnerUnavailable).toBe(false); }); it("should not restore a healthy session because of a previous turn's flag", async () => { wrapper = mountO2AIChat({ isOpen: true }); const vm = wrapper.vm as any; const sessionId = "11111111-1111-7111-8111-111111111111"; vm.currentSessionId = sessionId; vm.streamOwnerUnavailable = true; // Immediately-closed but clean, so the turn reaches the restore check. // An early failure would exit first and pass for the wrong reason. mockFetchAiChat.mockResolvedValueOnce({ ok: true, body: { getReader: () => ({ read: () => Promise.resolve({ done: true, value: undefined }), releaseLock: () => {}, cancel: () => Promise.resolve(), }), }, }); vm.inputMessage = "follow-up question"; await vm.sendMessage(); await flushPromises(); // Exactly one request: the stale flag must not trigger a restoring // second one, which would also have replaced the session id. expect(mockFetchAiChat).toHaveBeenCalledTimes(1); expect(vm.currentSessionId).toBe(sessionId); }); }); describe("restore and detached streams", () => { // A detached turn keeps writing to the array it captured, but must not // restore — chatMessages.value now points at a different conversation. /** A reader that reports an owner-unavailable stream, then closes. */ const ownerLostReader = (vm: any, onRead?: () => void) => ({ getReader: () => ({ read: () => { vm.streamOwnerUnavailable = true; onRead?.(); return Promise.resolve({ done: true, value: undefined }); }, releaseLock: () => {}, cancel: () => Promise.resolve(), }), }); it("should restore when the turn is still the one on screen", async () => { wrapper = mountO2AIChat({ isOpen: true }); const vm = wrapper.vm as any; const original = "11111111-1111-7111-8111-111111111111"; vm.currentSessionId = original; mockFetchAiChat .mockResolvedValueOnce({ ok: true, body: ownerLostReader(vm) }) .mockResolvedValueOnce({ ok: true, body: { getReader: () => ({ read: () => Promise.resolve({ done: true, value: undefined }), releaseLock: () => {}, cancel: () => Promise.resolve(), }), }, }); vm.inputMessage = "keep going"; await vm.sendMessage(); await flushPromises(); // Resent under a fresh id. expect(mockFetchAiChat).toHaveBeenCalledTimes(2); expect(vm.currentSessionId).not.toBe(original); }); it("should NOT restore into whichever chat the user switched to", async () => { wrapper = mountO2AIChat({ isOpen: true }); const vm = wrapper.vm as any; const original = "11111111-1111-7111-8111-111111111111"; vm.currentSessionId = original; // Mid-stream the user opens another chat: loadChat replaces the array, // which is how the component detects detachment elsewhere. const otherChat = [{ role: "user", content: "a different conversation" }]; mockFetchAiChat.mockResolvedValueOnce({ ok: true, body: ownerLostReader(vm, () => { vm.chatMessages = otherChat; }), }); vm.inputMessage = "keep going"; await vm.sendMessage(); await flushPromises(); // No resend, and the chat now on screen keeps its session id and // transcript. expect(mockFetchAiChat).toHaveBeenCalledTimes(1); expect(vm.currentSessionId).toBe(original); expect(otherChat).toHaveLength(1); }); }); describe("appendErrorBlock", () => { it("should attach to the trailing assistant message", () => { wrapper = mountO2AIChat({ isOpen: true }); (wrapper.vm as any).chatMessages = [ { role: "user", content: "hi" }, { role: "assistant", content: "hello", contentBlocks: [] }, ]; (wrapper.vm as any).appendErrorBlock("something went wrong"); const msgs = (wrapper.vm as any).chatMessages; expect(msgs).toHaveLength(2); expect(msgs[1].contentBlocks).toHaveLength(1); expect(msgs[1].contentBlocks[0].message).toBe("something went wrong"); }); it("should start a new assistant message when the last is from the user", () => { wrapper = mountO2AIChat({ isOpen: true }); (wrapper.vm as any).chatMessages = [{ role: "user", content: "hi" }]; (wrapper.vm as any).appendErrorBlock("restored", true); const msgs = (wrapper.vm as any).chatMessages; expect(msgs).toHaveLength(2); expect(msgs[1].role).toBe("assistant"); expect(msgs[1].contentBlocks[0].recoverable).toBe(true); }); }); }); });