mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
931 lines
32 KiB
JavaScript
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("<script>");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|