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