Files
openobserve/web/src/components/O2AIChat.keyboard.spec.ts
T

672 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});