mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
672 lines
24 KiB
TypeScript
672 lines
24 KiB
TypeScript
// 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 <http://www.gnu.org/licenses/>.
|
||
|
||
// 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: '<div data-test="rich-text-input" />',
|
||
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<any> | 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 <br>
|
||
const editable = editableAt("one<br>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);
|
||
});
|
||
});
|