mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
1429 lines
50 KiB
TypeScript
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***");
|
|
});
|
|
});
|
|
});
|