Files
OwnCord/Client/tests/unit/message-input.test.ts
T
J3vbandClaude 7365a31b45 refactor: flatten Client/tauri-client into Client (B1-1) (#1411)
* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 08:55:06 +02:00

1429 lines
50 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/** Captured emoji picker callbacks so tests can simulate selection. */
let lastEmojiPickerOptions: { onSelect: (emoji: string) => void; onClose: () => void } | null =
null;
vi.mock("@components/EmojiPicker", () => ({
createEmojiPicker: (opts: { onSelect: (emoji: string) => void; onClose: () => void }) => {
lastEmojiPickerOptions = opts;
const element = document.createElement("div");
element.classList.add("emoji-picker");
return { element, destroy: vi.fn() };
},
}));
/** Captured GIF picker callbacks so tests can simulate selection. */
type CapturedGifPickerOptions = {
onSelect: (url: string) => void;
onClose: () => void;
onUnavailable?: (reason: string) => void;
};
let lastGifPickerOptions: CapturedGifPickerOptions | null = null;
vi.mock("@components/GifPicker", () => ({
createGifPicker: (opts: CapturedGifPickerOptions) => {
lastGifPickerOptions = opts;
const element = document.createElement("div");
element.classList.add("gif-picker");
return { element, destroy: vi.fn() };
},
}));
import {
createMessageInput,
wrapWithMarker,
type MessageInputOptions,
} from "@components/MessageInput";
import type { GifApi } from "@lib/gifProvider";
/** GIF endpoints on the user's own server (never api.klipy.com). */
const stubGifApi: GifApi = {
gifSearch: vi.fn(async () => ({ results: [] })),
gifTrending: vi.fn(async () => ({ results: [] })),
};
function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions {
return {
channelId: 1,
channelName: "general",
gifApi: stubGifApi,
onSend: vi.fn(),
onTyping: vi.fn(),
onEditMessage: vi.fn(),
...overrides,
};
}
describe("MessageInput", () => {
let container: HTMLDivElement;
beforeEach(() => {
lastEmojiPickerOptions = null;
lastGifPickerOptions = null;
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("mounts with message-input-wrap class", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
expect(container.querySelector(".message-input-wrap")).not.toBeNull();
comp.destroy?.();
});
it("has textarea with correct placeholder", () => {
const opts = makeOptions({ channelName: "random" });
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
expect(textarea).not.toBeNull();
expect(textarea.placeholder).toBe("Message #random");
comp.destroy?.();
});
it("send button click calls onSend with textarea content", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "Hello world";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("Hello world", null, []);
comp.destroy?.();
});
it("enter key sends message", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "Enter message";
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []);
comp.destroy?.();
});
it("shift+enter does NOT send (just newlines)", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "Line 1";
textarea.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }),
);
expect(opts.onSend).not.toHaveBeenCalled();
comp.destroy?.();
});
it("empty textarea does not send", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).not.toHaveBeenCalled();
comp.destroy?.();
});
it("setReplyTo shows reply bar", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(42, "testuser");
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(true);
expect(replyBar.textContent).toContain("testuser");
comp.destroy?.();
});
it("clearReply hides reply bar", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(42, "testuser");
comp.clearReply();
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(false);
comp.destroy?.();
});
it("startEdit sets textarea value and shows edit bar", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(99, "editing this");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
expect(textarea.value).toBe("editing this");
// The edit bar is the second .reply-bar
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
it("cancelEdit clears textarea and hides edit bar", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(99, "editing this");
comp.cancelEdit();
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
expect(textarea.value).toBe("");
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(false);
comp.destroy?.();
});
it("typing emits onTyping (throttled)", () => {
vi.useFakeTimers();
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
// First input should trigger onTyping
textarea.dispatchEvent(new Event("input", { bubbles: true }));
expect(opts.onTyping).toHaveBeenCalledTimes(1);
// Immediate second input should NOT trigger (throttled at 3s)
textarea.dispatchEvent(new Event("input", { bubbles: true }));
expect(opts.onTyping).toHaveBeenCalledTimes(1);
// After 3 seconds, should fire again
vi.advanceTimersByTime(3000);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
expect(opts.onTyping).toHaveBeenCalledTimes(2);
vi.useRealTimers();
comp.destroy?.();
});
it("attach button is disabled with tooltip", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
expect(attachBtn).not.toBeNull();
expect(attachBtn.disabled).toBe(true);
expect(attachBtn.title).toBe("File uploads not available");
comp.destroy?.();
});
it("debounces rapid sends", () => {
vi.useFakeTimers();
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
textarea.value = "msg1";
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledTimes(1);
// Immediately try to send again (within 200ms debounce)
textarea.value = "msg2";
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledTimes(1); // still 1
// After debounce period
vi.advanceTimersByTime(200);
textarea.value = "msg3";
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledTimes(2);
vi.useRealTimers();
comp.destroy?.();
});
// ── Edit mode sends via onEditMessage ──
it("sending in edit mode calls onEditMessage instead of onSend", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(77, "old content");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "updated content";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onEditMessage).toHaveBeenCalledWith(77, "updated content");
expect(opts.onSend).not.toHaveBeenCalled();
// After send, edit bar should be hidden and textarea cleared
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(false);
expect(textarea.value).toBe("");
comp.destroy?.();
});
// ── Emptied edits are refused, never submitted ──
it("does not submit an emptied edit while an attachment is queued", async () => {
const uploadResult = { id: "srv-9", url: "http://server/pic.png", filename: "pic.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
// Queue an attachment first, then enter edit mode.
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
// Wait for the upload to fully settle so the send is not blocked by the
// uploads-in-flight guard instead of the empty-content one.
const previewBar = container.querySelector(".attachment-preview-bar");
await vi.waitFor(() => {
expect(previewBar!.querySelector(".uploading")).toBeNull();
});
comp.startEdit(77, "old content");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
// Same as with no attachment queued: the empty edit is refused and edit
// mode survives.
expect(opts.onEditMessage).not.toHaveBeenCalled();
expect(opts.onSend).not.toHaveBeenCalled();
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
// Typing real content and sending again still submits the edit.
textarea.value = "fixed content";
sendBtn.click();
expect(opts.onEditMessage).toHaveBeenCalledWith(77, "fixed content");
comp.destroy?.();
});
it("emptied edit with no attachment queued is a no-op that stays in edit mode", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(88, "old content");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onEditMessage).not.toHaveBeenCalled();
expect(opts.onSend).not.toHaveBeenCalled();
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
it("sends an attachment-only message with no text", async () => {
const uploadResult = { id: "srv-7", url: "http://server/pic.png", filename: "pic.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
const previewBar = container.querySelector(".attachment-preview-bar");
await vi.waitFor(() => {
expect(previewBar!.querySelector(".uploading")).toBeNull();
});
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("", null, ["srv-7"]);
comp.destroy?.();
});
// ── Reply context is included in send ──
it("sending with reply includes replyTo messageId", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(55, "replyuser");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "reply content";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("reply content", 55, []);
// Reply bar should be hidden after send
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(false);
comp.destroy?.();
});
// ── Escape key behavior ──
it("escape key cancels edit mode", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(88, "editing");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
// Edit bar should be hidden
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(false);
expect(textarea.value).toBe("");
comp.destroy?.();
});
it("escape key clears reply when not in edit mode", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(44, "replyuser");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(false);
comp.destroy?.();
});
// ── ArrowUp on empty textarea dispatches edit-last-message ──
it("ArrowUp on empty textarea dispatches edit-last-message custom event", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const listener = vi.fn();
container.addEventListener("edit-last-message", listener);
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(listener).toHaveBeenCalledTimes(1);
comp.destroy?.();
});
it("ArrowUp with content in textarea does NOT dispatch edit-last-message", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "some text";
const listener = vi.fn();
container.addEventListener("edit-last-message", listener);
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(listener).not.toHaveBeenCalled();
comp.destroy?.();
});
// ── File attachment via onUploadFile ──
it("attach button is enabled when onUploadFile is provided", () => {
const opts = makeOptions({
onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
});
const comp = createMessageInput(opts);
comp.mount(container);
const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
expect(attachBtn.disabled).toBe(false);
comp.destroy?.();
});
it("file picker accept attribute only advertises extensions the MIME allowlist accepts", () => {
const opts = makeOptions({
onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
});
const comp = createMessageInput(opts);
comp.mount(container);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
// .rar/.7z were advertised here but rejected by ALLOWED_TYPES on pick —
// an always-rejected picker option.
expect(fileInput.accept).not.toContain(".rar");
expect(fileInput.accept).not.toContain(".7z");
comp.destroy?.();
});
it("file upload shows preview and sends attachment ID with message", async () => {
const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
// Simulate file selection via the hidden input
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).not.toBeNull();
const testFile = new File(["image data"], "test.png", { type: "image/png" });
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// Wait for the async upload to complete
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
// Preview bar should be visible
const previewBar = container.querySelector(".attachment-preview-bar");
expect(previewBar!.classList.contains("visible")).toBe(true);
// Now send a message -- should include the attachment ID
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "with attachment";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("with attachment", null, ["srv-123"]);
comp.destroy?.();
});
it("rejects file exceeding 100 MB size limit", async () => {
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
// Create a file > 100MB
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// Give async handlers a tick
await new Promise((r) => setTimeout(r, 10));
// Upload should NOT have been called
expect(onUploadFile).not.toHaveBeenCalled();
// Error message should be displayed
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("too large");
comp.destroy?.();
});
it("upload error makes the (initially hidden) preview bar visible", async () => {
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
// Nothing queued yet, so the preview bar starts without the "visible"
// class -- app.css only shows it via .visible.
const previewBar = container.querySelector(".attachment-preview-bar");
expect(previewBar!.classList.contains("visible")).toBe(false);
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
expect(previewBar!.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
it("preview bar loses 'visible' again once the error auto-dismisses with nothing else queued", async () => {
vi.useFakeTimers();
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await vi.advanceTimersByTimeAsync(10);
const previewBar = container.querySelector(".attachment-preview-bar");
expect(previewBar!.classList.contains("visible")).toBe(true);
await vi.advanceTimersByTimeAsync(4000);
expect(container.querySelector(".attachment-upload-error")).toBeNull();
expect(previewBar!.classList.contains("visible")).toBe(false);
vi.useRealTimers();
comp.destroy?.();
});
it("rejects unsupported file types", async () => {
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const badFile = new File(["exe data"], "bad.exe", { type: "application/x-msdownload" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [badFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(onUploadFile).not.toHaveBeenCalled();
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("is not a supported file type");
comp.destroy?.();
});
it("shows upload error when onUploadFile rejects", async () => {
const onUploadFile = vi.fn(async () => {
throw new Error("Server exploded");
});
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// Wait for the async rejection
await vi.waitFor(() => {
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("Server exploded");
});
comp.destroy?.();
});
it("blocks send while uploads are still in flight", async () => {
// Create an upload that never resolves during the test
let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
const onUploadFile = vi.fn(
() =>
new Promise<{ id: string; url: string; filename: string }>((res) => {
resolveUpload = res;
}),
);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const file = new File(["data"], "doc.txt", { type: "text/plain" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// The upload is in flight but not resolved
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "trying to send";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
// Should NOT have sent -- upload still pending
expect(opts.onSend).not.toHaveBeenCalled();
// Should show "wait" error
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("wait for uploads");
// Now resolve the upload
resolveUpload!({ id: "done", url: "http://x", filename: "doc.txt" });
await new Promise((r) => setTimeout(r, 10));
comp.destroy?.();
});
it("remove button removes attachment preview while upload is pending", async () => {
// Use a long-running upload so we can click remove while it's still pending
let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
const onUploadFile = vi.fn(
() =>
new Promise<{ id: string; url: string; filename: string }>((res) => {
resolveUpload = res;
}),
);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const file = new File(["text"], "file.txt", { type: "text/plain" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// Wait for the preview to appear (upload started but not resolved)
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalled();
});
// Preview bar should be visible with the uploading item
const previewBar = container.querySelector(".attachment-preview-bar");
expect(previewBar!.classList.contains("visible")).toBe(true);
// Remove the attachment while upload is still pending (tempId still matches)
const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
expect(removeBtn).not.toBeNull();
removeBtn.click();
// Preview bar should lose visible class (all attachments removed from list)
expect(previewBar!.classList.contains("visible")).toBe(false);
// The preview item DOM element should be removed from the bar
expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();
// Clean up the pending promise
resolveUpload!({ id: "done", url: "http://x", filename: "file.txt" });
await new Promise((r) => setTimeout(r, 10));
comp.destroy?.();
});
it("remove button removes attachment preview after the upload has completed", async () => {
const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const testFile = new File(["image data"], "test.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
// Wait for the upload to resolve — the entry's id is now the server id,
// not the tempId the remove button was created with.
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
const previewBar = container.querySelector(".attachment-preview-bar");
await vi.waitFor(() => {
expect(previewBar!.querySelector(".uploading")).toBeNull();
});
const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
expect(removeBtn).not.toBeNull();
removeBtn.click();
expect(previewBar!.classList.contains("visible")).toBe(false);
expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();
// Sending now must not include the removed attachment.
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "no attachment";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("no attachment", null, []);
comp.destroy?.();
});
it("refuses to attach a file while editing a message", async () => {
const onUploadFile = vi.fn(async () => ({
id: "srv-1",
url: "http://x.png",
filename: "x.png",
}));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(77, "old content");
const file = new File(["data"], "photo.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
// Must not upload, and must not silently ride along with the next
// ordinary send once the edit is cancelled.
expect(onUploadFile).not.toHaveBeenCalled();
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
comp.destroy?.();
});
// ── setReplyTo clears edit mode ──
it("setReplyTo hides edit bar if editing", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(88, "editing");
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
comp.setReplyTo(55, "replying");
// Edit bar hidden, reply bar shown
expect(editBar.classList.contains("visible")).toBe(false);
const replyBar = bars[0] as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
it("setReplyTo while editing clears the stale edit text from the textarea", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(88, "old message body");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
expect(textarea.value).toBe("old message body");
comp.setReplyTo(55, "replying");
// The edit text must not survive into reply mode -- otherwise Enter
// reposts it verbatim as a duplicate reply.
expect(textarea.value).toBe("");
comp.destroy?.();
});
// ── startEdit clears reply mode ──
it("startEdit hides reply bar if replying", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(55, "replying");
const bars = container.querySelectorAll(".reply-bar");
const replyBar = bars[0] as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(true);
comp.startEdit(88, "now editing");
// Reply bar hidden, edit bar shown
expect(replyBar.classList.contains("visible")).toBe(false);
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
// ── Reply close button ──
it("clicking reply close button hides reply bar", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.setReplyTo(42, "replyuser");
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
expect(replyBar.classList.contains("visible")).toBe(true);
const closeBtn = replyBar.querySelector(".reply-close") as HTMLElement;
closeBtn.click();
expect(replyBar.classList.contains("visible")).toBe(false);
comp.destroy?.();
});
// ── Textarea auto-resize ──
it("textarea height adjusts on input (auto-resize)", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
// After mount, textarea should have style.height set
// Just verify input event triggers without error (auto-resize runs)
textarea.value = "Line 1\nLine 2\nLine 3";
textarea.dispatchEvent(new Event("input", { bubbles: true }));
// Height should be set (not "auto")
expect(textarea.style.height).not.toBe("");
comp.destroy?.();
});
// ── Emoji picker ──
it("clicking emoji button opens emoji picker", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
emojiBtn.click();
const picker = container.querySelector(".emoji-picker");
expect(picker).not.toBeNull();
comp.destroy?.();
});
it("selecting emoji inserts it into textarea at cursor position", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "Hello world";
textarea.selectionStart = 6;
textarea.selectionEnd = 6;
// Open picker
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
emojiBtn.click();
expect(lastEmojiPickerOptions).not.toBeNull();
lastEmojiPickerOptions!.onSelect("🎉");
// Emoji should be inserted at cursor position
expect(textarea.value).toBe("Hello 🎉 world");
comp.destroy?.();
});
// ── GIF picker ──
it("clicking GIF button opens GIF picker", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
gifBtn.click();
const picker = container.querySelector(".gif-picker");
expect(picker).not.toBeNull();
comp.destroy?.();
});
it("selecting a GIF sends it as a message immediately", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
gifBtn.click();
expect(lastGifPickerOptions).not.toBeNull();
lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");
expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);
comp.destroy?.();
});
it("selecting a GIF sends it without discarding a typed draft", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "wait for it...";
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
gifBtn.click();
expect(lastGifPickerOptions).not.toBeNull();
lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");
// The GIF is sent as its own message...
expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);
// ...and the user's typed draft survives, instead of being overwritten
// and thrown away.
expect(textarea.value).toBe("wait for it...");
comp.destroy?.();
});
it("opening GIF picker closes emoji picker", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
// Open emoji picker first
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
emojiBtn.click();
expect(container.querySelector(".emoji-picker")).not.toBeNull();
// Open GIF picker
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
gifBtn.click();
// Emoji picker should be removed
expect(container.querySelector(".emoji-picker")).toBeNull();
// GIF picker should be present
expect(container.querySelector(".gif-picker")).not.toBeNull();
comp.destroy?.();
});
it("opening emoji picker closes GIF picker", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
// Open GIF picker first
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
gifBtn.click();
expect(container.querySelector(".gif-picker")).not.toBeNull();
// Open emoji picker
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
emojiBtn.click();
// GIF picker should be removed
expect(container.querySelector(".gif-picker")).toBeNull();
// Emoji picker should be present
expect(container.querySelector(".emoji-picker")).not.toBeNull();
comp.destroy?.();
});
// ── Destroy cleans up ──
it("destroy removes DOM and cleans up pickers", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
// Open a picker so there's state to clean up
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
emojiBtn.click();
comp.destroy?.();
expect(container.querySelector(".message-input-wrap")).toBeNull();
});
// ── Paste file handling ──
it("pasting an image file triggers upload", async () => {
const onUploadFile = vi.fn(async () => ({
id: "paste-1",
url: "http://x",
filename: "paste.png",
}));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
const file = new File(["img"], "paste.png", { type: "image/png" });
// ClipboardEvent is not available in jsdom, so create a plain Event and
// attach clipboardData manually
const pasteEvent = new Event("paste", { bubbles: true }) as Event & {
clipboardData?: { items: Array<{ kind: string; type: string; getAsFile(): File | null }> };
};
Object.defineProperty(pasteEvent, "clipboardData", {
value: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
},
});
textarea.dispatchEvent(pasteEvent);
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(file);
});
comp.destroy?.();
});
// ── Files with empty MIME type are rejected (security hardening) ──
it("files with empty MIME type are rejected", async () => {
const onUploadFile = vi.fn(async () => ({ id: "unk-1", url: "http://x", filename: "data" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const noTypeFile = new File(["data"], "mystery", { type: "" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [noTypeFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(onUploadFile).not.toHaveBeenCalled();
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("is not a supported file type");
comp.destroy?.();
});
// ── Attachment count cap (server hard-rejects >10 attachments) ──
it("refuses to queue an 11th attachment instead of uploading it", async () => {
const onUploadFile = vi.fn(async () => ({ id: "x", url: "http://x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
for (let i = 0; i < 11; i++) {
const file = new File(["data"], `file${i}.txt`, { type: "text/plain" });
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
}
// The server hard-rejects a chat_send with more than 10 attachments (as
// a parse error with no attachment-specific messaging), so the composer
// must never upload -- let alone queue -- an 11th one.
expect(onUploadFile).toHaveBeenCalledTimes(10);
expect(container.querySelectorAll(".attachment-preview-item").length).toBe(10);
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("10");
comp.destroy?.();
});
// ── Toggling emoji picker closed ──
it("clicking emoji button again closes the picker", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
// Open
emojiBtn.click();
expect(container.querySelector(".emoji-picker")).not.toBeNull();
// Close
emojiBtn.click();
expect(container.querySelector(".emoji-picker")).toBeNull();
comp.destroy?.();
});
// ── GIF affordance degrades when the server has no GIF provider ───────────
describe("GIF button degradation", () => {
it("is enabled when a gifApi is wired", () => {
const comp = createMessageInput(makeOptions());
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(false);
comp.destroy?.();
});
it("renders disabled with a visible reason when no gifApi is wired", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(true);
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
expect(gifBtn.getAttribute("aria-label")).toContain("not enabled");
comp.destroy?.();
});
it("does not open a picker when no gifApi is wired", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
(container.querySelector(".gif-btn") as HTMLElement).click();
expect(container.querySelector(".gif-picker")).toBeNull();
comp.destroy?.();
});
it("disables the GIF button when the picker reports the server has GIFs off", () => {
const comp = createMessageInput(makeOptions());
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
gifBtn.click();
lastGifPickerOptions!.onUnavailable!("GIFs are not enabled on this server");
expect(gifBtn.hasAttribute("disabled")).toBe(true);
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
comp.destroy?.();
});
it("keeps the GIF button disabled after the composer is re-enabled", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
comp.setDisabled("Read-only channel");
comp.setDisabled(null);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(true);
comp.destroy?.();
});
});
// -------------------------------------------------------------------------
// Formatting shortcuts
// -------------------------------------------------------------------------
describe("formatting shortcuts", () => {
/** Mount a composer with `value` selected from `start` to `end`. */
function mountWithSelection(
value: string,
start: number,
end: number,
): { textarea: HTMLTextAreaElement; destroy: () => void } {
const comp = createMessageInput(makeOptions());
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = value;
textarea.selectionStart = start;
textarea.selectionEnd = end;
return { textarea, destroy: () => comp.destroy?.() };
}
function press(textarea: HTMLTextAreaElement, key: string): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
key,
ctrlKey: true,
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(event);
return event;
}
it("Ctrl+B wraps the selection in **", () => {
const { textarea, destroy } = mountWithSelection("make this bold", 5, 9);
const event = press(textarea, "b");
expect(textarea.value).toBe("make **this** bold");
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe("this");
expect(event.defaultPrevented).toBe(true);
destroy();
});
it("Ctrl+I wraps the selection in *", () => {
const { textarea, destroy } = mountWithSelection("hello", 0, 5);
press(textarea, "i");
expect(textarea.value).toBe("*hello*");
destroy();
});
it("Ctrl+U wraps the selection in __ instead of opening the file picker", () => {
const onUploadFile = vi.fn();
const comp = createMessageInput(makeOptions({ onUploadFile }));
comp.mount(container);
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "hello";
textarea.selectionStart = 0;
textarea.selectionEnd = 5;
// The global Ctrl+U shortcut listens on document — the composer must
// stop the event before it gets there.
const globalHandler = vi.fn();
document.addEventListener("keydown", globalHandler);
press(textarea, "u");
expect(textarea.value).toBe("__hello__");
expect(globalHandler).not.toHaveBeenCalled();
expect(onUploadFile).not.toHaveBeenCalled();
document.removeEventListener("keydown", globalHandler);
comp.destroy?.();
});
it("inserts empty markers and parks the caret between them", () => {
const { textarea, destroy } = mountWithSelection("ab", 1, 1);
press(textarea, "b");
expect(textarea.value).toBe("a****b");
expect(textarea.selectionStart).toBe(3);
expect(textarea.selectionEnd).toBe(3);
destroy();
});
it("unwraps an already-bold selection", () => {
const { textarea, destroy } = mountWithSelection("**this**", 0, 8);
press(textarea, "b");
expect(textarea.value).toBe("this");
destroy();
});
it("leaves other Ctrl combos alone", () => {
const { textarea, destroy } = mountWithSelection("hello", 0, 5);
const event = press(textarea, "k");
expect(textarea.value).toBe("hello");
expect(event.defaultPrevented).toBe(false);
destroy();
});
it("does nothing while the composer is disabled", () => {
const comp = createMessageInput(makeOptions());
comp.mount(container);
comp.setDisabled("Read-only channel");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "hello";
textarea.selectionStart = 0;
textarea.selectionEnd = 5;
press(textarea, "b");
expect(textarea.value).toBe("hello");
comp.destroy?.();
});
});
describe("wrapWithMarker", () => {
it("wraps a selection and reselects the inner text", () => {
expect(wrapWithMarker("abc", 1, 2, "~~")).toEqual({
value: "a~~b~~c",
selectionStart: 3,
selectionEnd: 4,
});
});
it("unwraps markers that surround the selection", () => {
expect(wrapWithMarker("a**b**c", 3, 4, "**")).toEqual({
value: "abc",
selectionStart: 1,
selectionEnd: 2,
});
});
it("does not mistake a short selection for a wrapped one", () => {
expect(wrapWithMarker("**", 0, 2, "**").value).toBe("******");
});
it("wraps rather than mangles a selection spanning multiple already-wrapped spans", () => {
// The selection starts and ends with "*" but is not itself a single
// wrapped span — unwrapping it would destroy both interior spans.
const result = wrapWithMarker("*hello* world *bye*", 0, 19, "*");
expect(result.value).toBe("**hello* world *bye**");
});
it("wraps rather than downgrades bold text when italicizing", () => {
const result = wrapWithMarker("**bold**", 0, 8, "*");
expect(result.value).toBe("***bold***");
});
it("wraps rather than downgrades bold text when italicizing a double-clicked word", () => {
// Selecting only the word (as a double-click would), not the "**"
// markers themselves: start/end land just inside the bold pair, so
// the single "*" immediately outside each edge belongs to a "**"
// pair rather than being a matching "*" marker of its own.
const result = wrapWithMarker("**bold**", 2, 6, "*");
expect(result.value).toBe("***bold***");
});
});
});