Files
OwnCord/Client/tauri-client/tests/unit/message-input.test.ts
T
J3vbandClaude Fable 5 f1a673e87e fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

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

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

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

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

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

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

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

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

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

* style(client): prettier-format the dm-store test

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

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

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

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

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

* chore(ledger): record the 2026-08-21 hunt findings as fixed

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

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

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

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 06:34:58 +02:00

1429 lines
50 KiB
TypeScript

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