Files
OwnCord/Client/tauri-client/tests/unit/message-input.test.ts
T
J3vbandClaude Fable 5 b1fb56511d fix(client): batch of 15 client correctness fixes (#1366)
* fix(message-list): rebuild virtual window when scroll leaves the rendered range

Scroll-driven renderWindow calls previously never rebuilt the DOM, so
scrolling past the overscan showed only spacer blank space until an
unrelated data change forced a full re-render. The window now rebuilds
whenever the computed visible range is not fully contained in the
rendered one, keeping the no-op (and the existing rebuild rate limiter)
for ranges that are already rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(messages): refetch channel tail when revisiting a previously loaded channel

The server only delivers live message broadcasts for the focused channel,
so a channel's loaded window stops updating once the user switches away.
Switching channels now drops the left channel's loaded flag so the next
visit refetches the live tail, while keeping the old rows rendered until
the refetch merges in (pending/failed rows are preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(channel-sidebar): assign distinct slots when reordering channels with tied positions

Categories whose channels share a position value (the server does not
enforce uniqueness, and new channels default to position 0) previously
produced an empty or partial reorder on drop, leaving the final order
ambiguous. Tied slots are now nudged into a strictly increasing sequence
before being reassigned, while already-distinct groups keep their range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(embeds): keep the link-preview abort timer armed until the body is read

The link-preview fetch previously cleared its 5 s abort timer as soon as
response headers arrived, so reading the response body was unbounded in
time and size. The timer is now cleared in a finally after the body read,
so the timeout covers the whole request and the 50 KB parse cap applies
as documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller

The two floating controls were appended inside the overflow scroller, so
they were part of its scrollable overflow and translated away with the
content whenever the user scrolled up — precisely when they become
visible. They now anchor to a position:relative frame that wraps the
scroller, keeping them pinned to the viewport edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): reset the pinned input device before cycling the mic to default

Selecting the default microphone (or losing the selected one to a hot
unplug) only muted and unmuted the existing track, which kept capturing
from the previously pinned device. The shared cycle now resets the
capture device to the system default first so both paths actually reach
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): re-check supersession after camera publish before announcing camera on

A camera disable that completes while the enable's publishTrack call is
still in flight now causes the enable to unpublish and stop its track and
skip the enabled announcement, so the server's last word matches the local
state instead of reporting a stopped camera as on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): re-check supersession across the screenshare publish loop

A screenshare disable that completes while a publish in the enable loop is
still in flight now stops the loop before the remaining tracks are
published; the enable attempt unpublishes and stops all of its tracks and
skips the enabled announcement, so tracks held only by that attempt are
released and the server's last word matches the local state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(sidebar): rethrow channel modal API failures so modals can recover

The create/edit/delete channel callbacks caught API errors and only
showed a toast, so the awaiting modal never saw the failure and left its
submit button disabled with the in-flight label. The callbacks now
rethrow after toasting, letting each modal re-enable its button and
render its inline error so the user can retry without losing the form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(messages): carry pending and failed rows across the prepend trim

When a scroll-up page pushed a channel past the per-channel cap,
prependMessages trimmed the tail wholesale, deleting pending/failed
optimistic rows that hold the only copy of the user's composed text.
The trim now carries those rows across, matching the other
window-replacing writers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag

A store-driven sidebar re-render while a drag is in flight rebuilds the
channel rows, detaching the container captured at mousedown; detached
rows report all-zero rects, so the drop and the hover indicator could
never resolve. The global handlers now re-target the live row, its
container, and the store's current group snapshot before hit-testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(delete-channel-modal): re-arm the confirm button when a delete fails

The confirm button was only restored from the catch block, so a caller
that handled the failure itself and resolved left the button disabled on
'Deleting...' with no way to retry. Restoration now runs in a finally
block whenever the modal is still open, regardless of how the callback
settled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(dm-sidebar): keep the presence dot when an avatar image loads

The avatar swap cleared the whole circle before inserting the fetched
image, which also removed the online/idle/dnd/offline dot on 1:1 rows.
The initial now lives in its own node and only that node is replaced,
so the presence dot survives the image arriving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(formatting): compute the yesterday boundary from the calendar date

The relative-day fence post was derived by subtracting a fixed 24 hours
from local midnight, which lands inside the wrong calendar day when a
DST transition makes the local day 23 or 25 hours long. It is now built
from the calendar date directly, so hover and expanded message
timestamps keep the correct Yesterday label around transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(message-input): keep the empty-edit guard active while attachments are queued

Edits are text-only, so a queued attachment no longer bypasses the
empty-content guard while editing. Submitting an edit whose text was
cleared is now refused with edit mode intact, matching the behavior
when nothing is attached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

---------

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

1390 lines
48 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?.();
});
// ── 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***");
});
});
});