Files
OwnCord/Client/tauri-client/tests/unit/renderers.test.ts
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

1755 lines
63 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
formatTime,
formatFullDate,
formatMessageTimestamp,
isSameDay,
shouldGroup,
renderDayDivider,
renderMessage,
renderMentions,
renderMentionSegment,
renderInlineContent,
renderMessageContent,
getUserRole,
roleColorVar,
GROUP_THRESHOLD_MS,
setServerHost,
} from "../../src/components/message-list/renderers";
import type { Message } from "../../src/stores/messages.store";
import { membersStore } from "../../src/stores/members.store";
import { channelsStore, setRoles } from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import type { MessageListOptions } from "../../src/components/MessageList";
import {
clearReactionUsersCache,
setReactionUsersFetcher,
} from "../../src/components/message-list/reaction-tooltip";
function resetStores(): void {
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
channelsStore.setState((prev) => ({ ...prev, roles: [] }));
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
}
/** Seed the member list so @tokens resolve — unresolvable tokens stay plain text. */
function seedMentionMembers(): void {
membersStore.setState((prev) => ({
...prev,
members: new Map([
[10, { id: 10, username: "alice", avatar: null, role: "member", status: "online" as const }],
[11, { id: 11, username: "bob", avatar: null, role: "member", status: "online" as const }],
]),
}));
}
function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
channelId: 1,
user: { id: 10, username: "Alice", avatar: null },
content: "Hello world",
replyTo: null,
attachments: [],
reactions: [],
pinned: false,
editedAt: null,
deleted: false,
timestamp: "2025-01-15T12:30:00Z",
status: "sent",
correlationId: null,
errorCode: null,
...overrides,
};
}
function makeOpts(overrides: Partial<MessageListOptions> = {}): MessageListOptions {
return {
channelId: 1,
channelName: "general",
currentUserId: 10,
onScrollTop: vi.fn(),
onReplyClick: vi.fn(),
onEditClick: vi.fn(),
onDeleteClick: vi.fn(),
onReactionClick: vi.fn(),
onPinClick: vi.fn(),
...overrides,
};
}
describe("renderers", () => {
let container: HTMLDivElement;
beforeEach(() => {
resetStores();
// jsdom sets window.location.origin to "null", which breaks new URL(relativeUrl, origin).
// Set a server host so resolveServerUrl converts relative paths to absolute URLs before isSafeUrl parses them.
setServerHost("localhost:8080");
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
setServerHost("");
container.remove();
});
describe("formatTime", () => {
it("formats ISO timestamp to HH:MM", () => {
const result = formatTime("2025-01-15T09:05:00Z");
// Result depends on timezone but should be formatted as HH:MM
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("formatFullDate", () => {
it("formats ISO timestamp to full date string", () => {
const result = formatFullDate("2025-01-15T12:00:00Z");
expect(result).toContain("2025");
expect(result).toContain("January");
});
});
describe("isSameDay", () => {
it("returns true for timestamps on the same day", () => {
expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-15T20:00:00Z")).toBe(true);
});
it("returns false for timestamps on different days", () => {
expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-16T08:00:00Z")).toBe(false);
});
});
describe("shouldGroup", () => {
it("returns true for same user within threshold", () => {
const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" });
const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:04:00Z" });
expect(shouldGroup(prev, curr)).toBe(true);
});
it("returns false for different users", () => {
const prev = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const curr = makeMessage({
id: 2,
user: { id: 20, username: "Bob", avatar: null },
timestamp: "2025-01-15T12:31:00Z",
});
expect(shouldGroup(prev, curr)).toBe(false);
});
it("returns false when time difference exceeds threshold", () => {
const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" });
const curr = makeMessage({
id: 2,
timestamp: "2025-01-15T12:06:00Z",
});
expect(shouldGroup(prev, curr)).toBe(false);
});
it("returns false when either message is deleted", () => {
const prev = makeMessage({ deleted: true });
const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:31:00Z" });
expect(shouldGroup(prev, curr)).toBe(false);
});
});
describe("renderDayDivider", () => {
it("creates a day divider element with formatted date", () => {
const divider = renderDayDivider("2025-01-15T12:00:00Z");
container.appendChild(divider);
expect(divider.classList.contains("msg-day-divider")).toBe(true);
const dateEl = divider.querySelector(".date");
expect(dateEl).not.toBeNull();
expect(dateEl!.textContent).toContain("January");
expect(dateEl!.textContent).toContain("2025");
});
it("includes line elements", () => {
const divider = renderDayDivider("2025-01-15T12:00:00Z");
const lines = divider.querySelectorAll(".line");
expect(lines.length).toBe(2);
});
});
describe("renderMentions", () => {
it("wraps resolvable @mentions in span with mention class", () => {
seedMentionMembers();
const fragment = renderMentions("Hello @alice how are you?");
container.appendChild(fragment);
const mention = container.querySelector(".mention");
expect(mention).not.toBeNull();
expect(mention!.textContent).toBe("@alice");
});
it("renders plain text without mentions", () => {
const fragment = renderMentions("Hello world");
container.appendChild(fragment);
expect(container.querySelector(".mention")).toBeNull();
expect(container.textContent).toBe("Hello world");
});
it("handles multiple mentions", () => {
seedMentionMembers();
const fragment = renderMentions("@alice and @bob");
container.appendChild(fragment);
const mentions = container.querySelectorAll(".mention");
expect(mentions.length).toBe(2);
});
});
describe("renderMessage", () => {
it("renders a basic message with author and content", () => {
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(el.getAttribute("data-testid")).toBe("message-1");
expect(container.querySelector(".msg-author")?.textContent).toBe("Alice");
expect(container.querySelector(".msg-text")?.textContent).toBe("Hello world");
ac.abort();
});
it("renders grouped messages with grouped class", () => {
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal);
expect(el.classList.contains("grouped")).toBe(true);
ac.abort();
});
it("marks a pending optimistic row and hides the action bar", () => {
const msg = makeMessage({ status: "pending", correlationId: "c1", id: 0 });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(el.classList.contains("pending")).toBe(true);
// No hover actions on an unsent row.
expect(container.querySelector(".msg-actions-bar")).toBeNull();
ac.abort();
});
it("renders a failed row with reason, retry, and discard", () => {
const onRetry = vi.fn();
const onDeleteDraft = vi.fn();
const msg = makeMessage({
status: "failed",
correlationId: "c1",
id: 0,
errorCode: "SLOW_MODE",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts({ onRetry, onDeleteDraft }), ac.signal);
container.appendChild(el);
expect(el.classList.contains("failed")).toBe(true);
expect(container.querySelector(".msg-send-failed-text")?.textContent).toContain("Slow mode");
const retry = container.querySelector<HTMLButtonElement>('[data-testid="msg-retry-c1"]');
const discard = container.querySelector<HTMLButtonElement>('[data-testid="msg-discard-c1"]');
expect(retry).not.toBeNull();
expect(discard).not.toBeNull();
retry!.click();
discard!.click();
expect(onRetry).toHaveBeenCalledWith("c1");
expect(onDeleteDraft).toHaveBeenCalledWith("c1");
ac.abort();
});
it("renders a transport-failure (NETWORK) row with the connection-problem reason", () => {
const msg = makeMessage({
status: "failed",
correlationId: "c2",
id: 0,
errorCode: "NETWORK",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(el.classList.contains("failed")).toBe(true);
expect(container.querySelector(".msg-send-failed-text")?.textContent).toBe(
"Connection problem — message not sent",
);
ac.abort();
});
it("renders deleted message with italic text", () => {
const msg = makeMessage({ deleted: true });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const text = container.querySelector(".msg-text");
expect(text?.textContent).toBe("[message deleted]");
expect((text as HTMLElement)?.style.fontStyle).toBe("italic");
ac.abort();
});
it("shows (edited) tag for edited messages", () => {
const msg = makeMessage({ editedAt: "2025-01-15T13:00:00Z" });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const edited = container.querySelector(".msg-edited");
expect(edited).not.toBeNull();
expect(edited!.textContent).toBe("(edited)");
ac.abort();
});
it("renders system messages differently", () => {
const msg = makeMessage({
user: { id: 0, username: "System", avatar: null },
content: "Alice joined the server",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".system-msg")).not.toBeNull();
ac.abort();
});
it("renders reply reference when replyTo is set", () => {
const original = makeMessage({ id: 1, content: "Original message" });
const reply = makeMessage({ id: 2, replyTo: 1, content: "This is a reply" });
const ac = new AbortController();
const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal);
container.appendChild(el);
const replyRef = container.querySelector(".msg-reply-ref");
expect(replyRef).not.toBeNull();
expect(replyRef!.querySelector(".rr-author")?.textContent).toBe("Alice");
ac.abort();
});
it("shows action buttons for non-deleted messages", () => {
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const actionsBar = container.querySelector(".msg-actions-bar");
expect(actionsBar).not.toBeNull();
ac.abort();
});
it("gives icon-only action buttons explicit accessible names", () => {
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(
container.querySelector("[data-testid='msg-react-1']")?.getAttribute("aria-label"),
).toBe("React");
expect(
container.querySelector("[data-testid='msg-reply-1']")?.getAttribute("aria-label"),
).toBe("Reply");
expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe(
"Pin",
);
expect(
container.querySelector("[data-testid='msg-edit-1']")?.getAttribute("aria-label"),
).toBe("Edit");
expect(
container.querySelector("[data-testid='msg-delete-1']")?.getAttribute("aria-label"),
).toBe("Delete");
ac.abort();
});
it("updates the pin button accessible name for pinned messages", () => {
const msg = makeMessage({ pinned: true });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe(
"Unpin",
);
ac.abort();
});
it("does not show action buttons for deleted messages", () => {
const msg = makeMessage({ deleted: true });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const actionsBar = container.querySelector(".msg-actions-bar");
expect(actionsBar).toBeNull();
ac.abort();
});
it("renders reactions when present", () => {
const msg = makeMessage({
reactions: [
{ emoji: "\uD83D\uDC4D", count: 3, me: false },
{ emoji: "\u2764\uFE0F", count: 1, me: true },
],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const reactionChips = container.querySelectorAll(".reaction-chip:not(.add-reaction)");
expect(reactionChips.length).toBe(2);
ac.abort();
});
// The who-reacted tooltip hangs off the pill, so the pill has to carry its
// emoji and be focusable for a keyboard user to reach the tooltip at all.
it("makes reaction pills focusable and tags them with their emoji", () => {
const msg = makeMessage({
reactions: [{ emoji: "\uD83D\uDC4D", count: 3, me: false }],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const chip = container.querySelector(
".reaction-chip:not(.add-reaction)",
) as HTMLElement | null;
expect(chip?.dataset.emoji).toBe("\uD83D\uDC4D");
expect(chip?.getAttribute("tabindex")).toBe("0");
ac.abort();
});
it("shows the who-reacted tooltip after hovering a pill", async () => {
setReactionUsersFetcher(() =>
Promise.resolve([
{ id: 1, username: "alice", avatar: "" },
{ id: 2, username: "bob", avatar: "" },
]),
);
clearReactionUsersCache();
const msg = makeMessage({
reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: false }],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const chip = container.querySelector(".reaction-chip:not(.add-reaction)") as HTMLElement;
chip.dispatchEvent(new Event("mouseenter"));
await vi.waitFor(() => {
expect(chip.querySelector(".reaction-tooltip-names")?.textContent).toBe("alice and bob");
});
ac.abort();
setReactionUsersFetcher(null);
});
it("renders attachments for image types", () => {
const msg = makeMessage({
attachments: [
{
id: "1",
filename: "photo.png",
size: 1024,
mime: "image/png",
url: "/uploads/photo.png",
},
],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-image")).not.toBeNull();
ac.abort();
});
it("renders attachments for file types", () => {
const msg = makeMessage({
attachments: [
{
id: "1",
filename: "doc.pdf",
size: 2048,
mime: "application/pdf",
url: "/uploads/doc.pdf",
},
],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-file")).not.toBeNull();
expect(container.querySelector(".msg-file-name")?.textContent).toBe("doc.pdf");
ac.abort();
});
});
// ---------------------------------------------------------------------------
// parseTimestamp (via formatTime / formatFullDate)
// ---------------------------------------------------------------------------
describe("parseTimestamp — UTC treatment of bare SQLite timestamps", () => {
it("treats a SQLite datetime string without timezone as UTC", () => {
// "2026-03-19 08:00:00" is SQLite output — no Z, no T, no offset.
// parseTimestamp must append Z so it is read as 08:00 UTC, not local.
const withZ = formatTime("2026-03-19T08:00:00Z");
const sqliteBare = formatTime("2026-03-19 08:00:00");
expect(sqliteBare).toBe(withZ);
});
it("preserves explicit UTC timestamps with Z suffix unchanged", () => {
// Verify that a Z-suffixed timestamp produces a valid HH:MM string.
// We cannot assert a specific value because formatTime uses local clock
// hours — instead verify that the bare and Z forms agree.
const withZ = formatTime("2026-03-19T15:30:00Z");
expect(withZ).toMatch(/^\d{2}:\d{2}$/);
});
it("preserves explicit positive UTC-offset timestamps", () => {
// +02:00 is two hours ahead of UTC, so 17:30+02:00 == 15:30Z.
// Both forms should produce the same local-clock HH:MM string.
const withOffset = formatTime("2026-03-19T17:30:00+02:00");
const utcEquiv = formatTime("2026-03-19T15:30:00Z");
expect(withOffset).toBe(utcEquiv);
});
it("formats full date correctly for bare SQLite timestamp", () => {
// Midday UTC, not midnight: formatFullDate renders in the local zone, and
// a UTC-midnight fixture rolls back to the 18th in any zone west of UTC
// (e.g. America/New_York). Noon UTC stays on the 19th for every zone
// from UTC-11 to UTC+11, which covers every developer/CI machine zone
// (only the rare UTC+12..+14 Pacific zones roll it forward to the 20th).
const result = formatFullDate("2026-03-19 12:00:00");
expect(result).toContain("2026");
expect(result).toContain("March");
expect(result).toContain("19");
});
it("handles ISO 8601 datetime with T separator and Z suffix", () => {
// Standard ISO — must not double-append Z. The result must be a
// valid HH:MM string and must equal what the same timestamp produces
// when formatted normally.
const iso = "2026-01-01T06:00:00Z";
const result = formatTime(iso);
expect(result).toMatch(/^\d{2}:\d{2}$/);
// Confirm idempotency: calling again with the same input gives same output
expect(formatTime(iso)).toBe(result);
});
it("returns a valid HH:MM string for every supported timestamp format", () => {
const formats = ["2026-03-19T08:30:00Z", "2026-03-19 08:30:00", "2026-03-19T08:30:00+00:00"];
for (const ts of formats) {
expect(formatTime(ts)).toMatch(/^\d{2}:\d{2}$/);
}
});
});
// ---------------------------------------------------------------------------
// isDirectImageUrl (via renderMessage embed output)
// ---------------------------------------------------------------------------
describe("isDirectImageUrl — image extension detection via renderMessage", () => {
const imageExtensions = [".gif", ".png", ".jpg", ".jpeg", ".webp"] as const;
for (const ext of imageExtensions) {
it(`produces a .msg-image embed for a ${ext} URL`, () => {
const url = `https://example.com/image${ext}`;
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
// renderUrlEmbeds calls isDirectImageUrl → renderInlineImage
// which produces a div.msg-image inside the message element
const imageEmbeds = container.querySelectorAll(".msg-image");
// There may be multiple .msg-image (attachments share the class),
// but at least one must exist and have an <img src="...ext">
const imgEl = container.querySelector(`.msg-image img[src="${url}"]`);
expect(imgEl).not.toBeNull();
ac.abort();
});
}
it("does not produce a .msg-image embed for a non-image URL", () => {
const url = "https://example.com/page.html";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
// A generic link card (.msg-embed-link) should appear instead
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
ac.abort();
});
it("does not produce a .msg-image embed for a URL with image extension as query param", () => {
// The path itself has no image extension — only a query string does
const url = "https://example.com/image?format=png";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
ac.abort();
});
it("matches image extensions case-insensitively", () => {
// Uppercase extensions must also be detected
const url = "https://example.com/photo.PNG";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const imgEl = container.querySelector(`.msg-image img[src="${url}"]`);
expect(imgEl).not.toBeNull();
ac.abort();
});
it("does not treat a YouTube URL with no image extension as a direct image", () => {
const url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
// YouTube gets a player embed, not a .msg-image with that src
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
expect(container.querySelector(".msg-embed-youtube")).not.toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// renderInlineImage (via renderMessage)
// ---------------------------------------------------------------------------
describe("renderInlineImage — img element and lightbox behaviour", () => {
it("creates an img element with the correct src attribute", () => {
const url = "https://example.com/photo.jpg";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const img = container.querySelector(`.msg-image img`) as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img!.getAttribute("src")).toBe(url);
ac.abort();
});
it("wraps the img in a div with class msg-image", () => {
const url = "https://cdn.example.com/banner.gif";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const wrap = container.querySelector(".msg-image");
expect(wrap).not.toBeNull();
expect(wrap!.querySelector("img")).not.toBeNull();
ac.abort();
});
it("appends a lightbox overlay to document.body on img click", () => {
const url = "https://example.com/photo.png";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
expect(img).not.toBeNull();
// No lightbox before click
expect(document.body.querySelector(".image-lightbox")).toBeNull();
img!.click();
// Lightbox should now be in the body
const lightbox = document.body.querySelector(".image-lightbox");
expect(lightbox).not.toBeNull();
// Clean up lightbox
lightbox!.remove();
ac.abort();
});
it("lightbox contains a close button that removes the overlay", () => {
const url = "https://example.com/photo.webp";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
img!.click();
const lightbox = document.body.querySelector(".image-lightbox")!;
const closeBtn = lightbox.querySelector(".image-lightbox-close") as HTMLElement | null;
expect(closeBtn).not.toBeNull();
closeBtn!.click();
// Lightbox should be removed from DOM after close
expect(document.body.querySelector(".image-lightbox")).toBeNull();
ac.abort();
});
it("lightbox contains an img element with the same src as the inline image", () => {
const url = "https://example.com/pic.jpeg";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
img!.click();
const lightbox = document.body.querySelector(".image-lightbox")!;
const lbImg = lightbox.querySelector("img") as HTMLImageElement | null;
expect(lbImg).not.toBeNull();
expect(lbImg!.getAttribute("src")).toBe(url);
// Clean up
lightbox.remove();
ac.abort();
});
it("closes lightbox when Escape key is pressed", () => {
const url = "https://example.com/escape-test.png";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
img!.click();
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(document.body.querySelector(".image-lightbox")).toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// System messages
// ---------------------------------------------------------------------------
describe("renderSystemMessage (via renderMessage)", () => {
it("renders system message with icon, text, and time", () => {
const msg = makeMessage({
user: { id: 0, username: "System", avatar: null },
content: "@alice joined the server",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".system-msg")).not.toBeNull();
expect(container.querySelector(".sm-icon")).not.toBeNull();
expect(container.querySelector(".sm-text")).not.toBeNull();
expect(container.querySelector(".sm-time")).not.toBeNull();
ac.abort();
});
it("renders mentions inside system message text", () => {
const msg = makeMessage({
user: { id: 0, username: "System", avatar: null },
content: "@alice was promoted to admin",
});
seedMentionMembers();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const mention = el.querySelector(".mention");
expect(mention).not.toBeNull();
expect(mention!.textContent).toBe("@alice");
ac.abort();
});
it("does not treat a real registered user named 'System' as a system notice", () => {
// Real user ids are DB autoincrement and start at 1 — only the
// synthetic sentinel row (id 0) is a genuine system message. A user
// who registered the display name "System" must render normally,
// with an author name and full moderation controls.
const msg = makeMessage({
user: { id: 42, username: "System", avatar: null },
content: "Your session was flagged — re-enter your password at evil.example",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".system-msg")).toBeNull();
expect(container.querySelector(".msg-author")).not.toBeNull();
// The hover action bar (react/reply/pin/…) is only built for real
// messages — renderSystemMessage returns before it exists at all.
expect(container.querySelector('[data-testid="msg-reply-1"]')).not.toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// Reply reference — unknown message
// ---------------------------------------------------------------------------
describe("renderReplyRef — unknown message", () => {
it("renders 'Reply to unknown message' when referenced message is missing", () => {
const reply = makeMessage({ id: 2, replyTo: 999, content: "replying" });
const ac = new AbortController();
const el = renderMessage(reply, false, [reply], makeOpts(), ac.signal);
container.appendChild(el);
const replyRef = container.querySelector(".msg-reply-ref");
expect(replyRef).not.toBeNull();
expect(replyRef!.textContent).toBe("Reply to unknown message");
ac.abort();
});
it("renders '[message deleted]' preview for deleted referenced message", () => {
const original = makeMessage({ id: 1, content: "Original", deleted: true });
const reply = makeMessage({ id: 2, replyTo: 1, content: "reply" });
const ac = new AbortController();
const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal);
container.appendChild(el);
const replyText = container.querySelector(".rr-text");
expect(replyText).not.toBeNull();
expect(replyText!.textContent).toBe("[message deleted]");
ac.abort();
});
});
// ---------------------------------------------------------------------------
// Developer mode — copy ID button
// ---------------------------------------------------------------------------
describe("developer mode — Copy ID button", () => {
it("renders Copy ID button when developerMode is enabled", () => {
// loadPref reads from localStorage with the "owncord:settings:" prefix
localStorage.setItem("owncord:settings:developerMode", "true");
// Dispatch pref-change to invalidate the cached developerModeEnabled value
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-1']");
expect(copyIdBtn).not.toBeNull();
expect(copyIdBtn!.getAttribute("aria-label")).toBe("Copy ID");
// Clean up: restore developer mode to false
localStorage.setItem("owncord:settings:developerMode", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
ac.abort();
});
it("Copy ID button calls clipboard.writeText on click", () => {
localStorage.setItem("owncord:settings:developerMode", "true");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
const msg = makeMessage({ id: 42 });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-42']") as HTMLElement;
expect(copyIdBtn).not.toBeNull();
copyIdBtn.click();
expect(writeTextMock).toHaveBeenCalledWith("42");
// Clean up
localStorage.setItem("owncord:settings:developerMode", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
ac.abort();
});
it("Copy ID button handles clipboard failure gracefully", () => {
localStorage.setItem("owncord:settings:developerMode", "true");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("clipboard unavailable")) },
});
const msg = makeMessage({ id: 42 });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-42']") as HTMLElement;
// Should not throw
expect(() => copyIdBtn.click()).not.toThrow();
// Clean up
localStorage.setItem("owncord:settings:developerMode", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
ac.abort();
});
it("does not render Copy ID button when developerMode is disabled", () => {
localStorage.setItem("owncord:settings:developerMode", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "developerMode" },
}),
);
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-1']");
expect(copyIdBtn).toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// Deleted messages — no action bar, no embeds
// ---------------------------------------------------------------------------
describe("deleted message edge cases", () => {
it("does not render embeds for deleted messages", () => {
const msg = makeMessage({
deleted: true,
content: "https://example.com/photo.png",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-image")).toBeNull();
expect(container.querySelector(".msg-embed-link")).toBeNull();
expect(container.querySelector(".msg-actions-bar")).toBeNull();
ac.abort();
});
it("does not render (edited) tag for deleted messages", () => {
const msg = makeMessage({
deleted: true,
editedAt: "2025-01-15T13:00:00Z",
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-edited")).toBeNull();
ac.abort();
});
it("does not render reactions for deleted messages", () => {
const msg = makeMessage({
deleted: true,
reactions: [{ emoji: "\uD83D\uDC4D", count: 1, me: false }],
});
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".reaction-chip")).toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// Action button callbacks
// ---------------------------------------------------------------------------
describe("action button callbacks", () => {
it("calls onReplyClick when reply button is clicked", () => {
const opts = makeOpts();
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
const replyBtn = container.querySelector("[data-testid='msg-reply-1']") as HTMLElement;
replyBtn.click();
expect(opts.onReplyClick).toHaveBeenCalledWith(1);
ac.abort();
});
it("calls onReactionClick when react button is clicked", () => {
const opts = makeOpts();
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
const reactBtn = container.querySelector("[data-testid='msg-react-1']") as HTMLElement;
reactBtn.click();
expect(opts.onReactionClick).toHaveBeenCalledWith(1, "");
ac.abort();
});
it("calls onPinClick with correct arguments", () => {
const opts = makeOpts();
const msg = makeMessage({ pinned: false });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
const pinBtn = container.querySelector("[data-testid='msg-pin-1']") as HTMLElement;
pinBtn.click();
expect(opts.onPinClick).toHaveBeenCalledWith(1, 1, false);
ac.abort();
});
it("calls onEditClick when edit button is clicked", () => {
const opts = makeOpts();
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
const editBtn = container.querySelector("[data-testid='msg-edit-1']") as HTMLElement;
editBtn.click();
expect(opts.onEditClick).toHaveBeenCalledWith(1);
ac.abort();
});
it("calls onDeleteClick when delete button is clicked", () => {
const opts = makeOpts();
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
const deleteBtn = container.querySelector("[data-testid='msg-delete-1']") as HTMLElement;
deleteBtn.click();
expect(opts.onDeleteClick).toHaveBeenCalledWith(1);
ac.abort();
});
it("does not render edit/delete buttons for other users' messages", () => {
const opts = makeOpts({ currentUserId: 999 });
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], opts, ac.signal);
container.appendChild(el);
expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull();
expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull();
// But react, reply, pin should still be present
expect(container.querySelector("[data-testid='msg-react-1']")).not.toBeNull();
expect(container.querySelector("[data-testid='msg-reply-1']")).not.toBeNull();
expect(container.querySelector("[data-testid='msg-pin-1']")).not.toBeNull();
ac.abort();
});
it("offers delete on others' messages to a role with MANAGE_MESSAGES", () => {
// MANAGE_MESSAGES = 0x10000 (see lib/types Permission).
setRoles([{ id: 2, name: "moderator", color: null, permissions: 0x10000 }]);
authStore.setState(() => ({
token: "tok",
user: { id: 999, username: "Mod", avatar: null, role: "moderator" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
const opts = makeOpts({ currentUserId: 999 });
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const ac = new AbortController();
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
expect(container.querySelector("[data-testid='msg-delete-1']")).not.toBeNull();
// Editing someone else's message is still not a thing.
expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull();
ac.abort();
});
it("withholds delete from a role without MANAGE_MESSAGES", () => {
setRoles([{ id: 3, name: "member", color: null, permissions: 0 }]);
authStore.setState(() => ({
token: "tok",
user: { id: 999, username: "Nobody", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
const opts = makeOpts({ currentUserId: 999 });
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const ac = new AbortController();
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
// Grouped message hover time
// ---------------------------------------------------------------------------
describe("grouped message hover time", () => {
it("renders hover time element for grouped messages", () => {
const msg = makeMessage();
const ac = new AbortController();
const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal);
container.appendChild(el);
const hoverTime = container.querySelector(".msg-hover-time");
expect(hoverTime).not.toBeNull();
expect(hoverTime!.textContent).toMatch(/^\d{2}:\d{2}$/);
ac.abort();
});
});
// ---------------------------------------------------------------------------
// formatMessageTimestamp
// ---------------------------------------------------------------------------
describe("formatMessageTimestamp", () => {
it("returns 'Today at ...' for today's timestamps", () => {
const now = new Date();
const iso = now.toISOString();
const result = formatMessageTimestamp(iso);
expect(result).toMatch(/^Today at /);
});
it("returns 'Yesterday at ...' for yesterday's timestamps", () => {
const yesterday = new Date(Date.now() - 86_400_000);
const iso = yesterday.toISOString();
const result = formatMessageTimestamp(iso);
expect(result).toMatch(/^Yesterday at /);
});
it("returns MM/DD/YYYY format for older timestamps", () => {
const result = formatMessageTimestamp("2020-06-15T12:00:00Z");
expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} /);
});
});
// Probe once, before the suite is even registered: `process.env.TZ`
// mutations only reach Date's local-time engine on the main thread/forks
// pool. A worker-thread pool (e.g. Stryker's vitest runner) spawns an
// isolate that never observes the change, even though the assignment
// itself succeeds. Skip the whole block there instead of failing on a pool
// limitation nothing in this file can fix; `npx vitest run` (forks, the
// documented way to run this suite) always honors the pin and runs it for
// real.
const dstProbeOriginalTZ = process.env.TZ;
process.env.TZ = "America/New_York";
const dstTZPinHonored =
new Date(2026, 0, 15).getTimezoneOffset() === 300 &&
new Date(2026, 6, 15).getTimezoneOffset() === 240;
if (dstProbeOriginalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = dstProbeOriginalTZ;
}
describe.skipIf(!dstTZPinHonored)("formatMessageTimestamp — DST day boundaries", () => {
// These cases only exist in a DST-observing zone, so pin one for the
// duration of this block. The precondition assertion in each test is
// cheap insurance against the pin drifting mid-run.
const originalTZ = process.env.TZ;
beforeEach(() => {
process.env.TZ = "America/New_York";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
if (originalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTZ;
}
});
function assertEasternTime(): void {
// EST is UTC-5 (offset 300), EDT is UTC-4 (offset 240). If these differ
// the runtime is genuinely observing DST transitions.
expect(new Date(2026, 0, 15).getTimezoneOffset()).toBe(300);
expect(new Date(2026, 6, 15).getTimezoneOffset()).toBe(240);
}
it("does not label a two-day-old message 'Yesterday' across spring forward", () => {
assertEasternTime();
// DST starts Sun Mar 8, 2026 (23-hour local day). Now: Mon Mar 9, 11:00 EDT.
vi.setSystemTime(new Date("2026-03-09T15:00:00Z"));
// Sat Mar 7, 23:30 EST — two calendar days before "today".
const result = formatMessageTimestamp("2026-03-08T04:30:00Z");
expect(result).not.toMatch(/^Yesterday/);
expect(result).toMatch(/^03\/07\/2026 /);
// Sun Mar 8, 08:00 EDT really is yesterday.
expect(formatMessageTimestamp("2026-03-08T12:00:00Z")).toMatch(/^Yesterday at /);
});
it("labels the whole previous calendar day 'Yesterday' across fall back", () => {
assertEasternTime();
// DST ends Sun Nov 1, 2026 (25-hour local day). Now: Mon Nov 2, 10:00 EST.
vi.setSystemTime(new Date("2026-11-02T15:00:00Z"));
// Sun Nov 1, 00:30 EDT — inside the previous calendar day.
expect(formatMessageTimestamp("2026-11-01T04:30:00Z")).toMatch(/^Yesterday at /);
// Sat Oct 31, 23:30 EDT — two calendar days back stays absolute.
expect(formatMessageTimestamp("2026-11-01T03:30:00Z")).toMatch(/^10\/31\/2026 /);
});
it("keeps Today/Yesterday/absolute labels on an ordinary day", () => {
assertEasternTime();
// Wed Jun 17, 2026, 11:00 EDT — nowhere near a DST transition.
vi.setSystemTime(new Date("2026-06-17T15:00:00Z"));
expect(formatMessageTimestamp("2026-06-17T14:00:00Z")).toMatch(/^Today at /);
expect(formatMessageTimestamp("2026-06-17T03:30:00Z")).toMatch(/^Yesterday at /);
expect(formatMessageTimestamp("2026-06-15T16:00:00Z")).toMatch(/^06\/15\/2026 /);
});
});
// ---------------------------------------------------------------------------
// getUserRole / roleColorVar
// ---------------------------------------------------------------------------
describe("getUserRole", () => {
it("returns 'member' for unknown userId", () => {
expect(getUserRole(999999)).toBe("member");
});
it("returns role for known member", () => {
membersStore.setState((prev) => ({
...prev,
members: new Map([
[42, { id: 42, username: "admin", avatar: null, role: "admin", status: "online" }],
]),
}));
expect(getUserRole(42)).toBe("admin");
});
});
describe("roleColorVar", () => {
afterEach(() => {
// Restore roleColors to default (true)
localStorage.setItem("owncord:settings:roleColors", "true");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "roleColors" },
}),
);
});
it("returns owner color for 'owner' role", () => {
expect(roleColorVar("owner")).toBe("var(--role-owner)");
});
it("returns admin color for 'admin' role", () => {
expect(roleColorVar("admin")).toBe("var(--role-admin)");
});
it("returns mod color for 'moderator' role", () => {
expect(roleColorVar("moderator")).toBe("var(--role-mod)");
});
it("returns member color for unknown role", () => {
expect(roleColorVar("custom")).toBe("var(--role-member)");
});
it("returns member color for 'member' role", () => {
expect(roleColorVar("member")).toBe("var(--role-member)");
});
it("returns member color for all roles when roleColors is disabled", () => {
localStorage.setItem("owncord:settings:roleColors", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "roleColors" },
}),
);
expect(roleColorVar("owner")).toBe("var(--role-member)");
expect(roleColorVar("admin")).toBe("var(--role-member)");
expect(roleColorVar("moderator")).toBe("var(--role-member)");
expect(roleColorVar("member")).toBe("var(--role-member)");
});
it("re-enables role colors when pref changes back to true", () => {
localStorage.setItem("owncord:settings:roleColors", "false");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "roleColors" },
}),
);
expect(roleColorVar("owner")).toBe("var(--role-member)");
localStorage.setItem("owncord:settings:roleColors", "true");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", {
detail: { key: "roleColors" },
}),
);
expect(roleColorVar("owner")).toBe("var(--role-owner)");
});
});
// ---------------------------------------------------------------------------
// renderMentionSegment
// ---------------------------------------------------------------------------
describe("renderMentionSegment", () => {
it("renders plain text without mentions", () => {
const fragment = renderMentionSegment("just text");
container.appendChild(fragment);
expect(container.textContent).toBe("just text");
expect(container.querySelector(".mention")).toBeNull();
});
it("renders @mention at start of text", () => {
seedMentionMembers();
const fragment = renderMentionSegment("@alice hello");
container.appendChild(fragment);
const mention = container.querySelector(".mention");
expect(mention).not.toBeNull();
expect(mention!.textContent).toBe("@alice");
});
});
// ---------------------------------------------------------------------------
// renderInlineContent
// ---------------------------------------------------------------------------
describe("renderInlineContent", () => {
it("renders inline code segments", () => {
const fragment = renderInlineContent("use `npm install` to install");
container.appendChild(fragment);
const code = container.querySelector("code");
expect(code).not.toBeNull();
expect(code!.textContent).toBe("npm install");
});
it("renders text with no inline code", () => {
const fragment = renderInlineContent("plain text");
container.appendChild(fragment);
expect(container.textContent).toBe("plain text");
expect(container.querySelector("code")).toBeNull();
});
it("renders mention inside non-code text", () => {
seedMentionMembers();
const fragment = renderInlineContent("hello @alice and `code`");
container.appendChild(fragment);
expect(container.querySelector(".mention")).not.toBeNull();
expect(container.querySelector("code")).not.toBeNull();
});
});
// ---------------------------------------------------------------------------
// renderMessageContent — code blocks
// ---------------------------------------------------------------------------
describe("renderMessageContent", () => {
it("renders code blocks with copy button", () => {
const fragment = renderMessageContent("```console.log('hi')```");
container.appendChild(fragment);
const codeBlock = container.querySelector(".msg-codeblock");
expect(codeBlock).not.toBeNull();
expect(codeBlock!.textContent).toBe("console.log('hi')");
const copyBtn = container.querySelector(".msg-codeblock-copy");
expect(copyBtn).not.toBeNull();
expect(copyBtn!.textContent).toBe("Copy");
});
it("renders prose and code blocks together", () => {
const fragment = renderMessageContent("Hello```code here```world");
container.appendChild(fragment);
expect(container.querySelector(".msg-text")).not.toBeNull();
expect(container.querySelector(".msg-codeblock")).not.toBeNull();
});
it("renders empty content as a single msg-text", () => {
const fragment = renderMessageContent("");
container.appendChild(fragment);
// Empty string content: parts = [""], single prose segment
const textEls = container.querySelectorAll(".msg-text");
expect(textEls.length).toBeLessThanOrEqual(1);
});
it("handles code block copy button click with clipboard", async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
});
const fragment = renderMessageContent("```test code```");
container.appendChild(fragment);
const copyBtn = container.querySelector(".msg-codeblock-copy") as HTMLElement;
copyBtn.click();
await vi.waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("test code");
});
});
it("handles code block copy button click when clipboard fails", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("fail")) },
});
const fragment = renderMessageContent("```some code```");
container.appendChild(fragment);
const copyBtn = container.querySelector(".msg-codeblock-copy") as HTMLElement;
copyBtn.click();
await vi.waitFor(() => {
expect(copyBtn.textContent).toBe("Failed");
});
});
});
// ---------------------------------------------------------------------------
// renderMentions — URL handling
// ---------------------------------------------------------------------------
describe("renderMentions — URL linkification", () => {
it("creates a clickable link for safe URLs", () => {
const fragment = renderMentions("check https://example.com out");
container.appendChild(fragment);
const link = container.querySelector("a.msg-link");
expect(link).not.toBeNull();
expect(link!.getAttribute("href")).toBe("https://example.com");
expect(link!.getAttribute("target")).toBe("_blank");
});
it("renders unsafe URLs as plain text", () => {
const fragment = renderMentions("see javascript:alert(1) here");
container.appendChild(fragment);
// No anchor tag for javascript: URL (doesn't match URL_REGEX at all)
expect(container.querySelector("a")).toBeNull();
});
it("handles URLs between text segments", () => {
const fragment = renderMentions("before https://a.com after");
container.appendChild(fragment);
const link = container.querySelector("a.msg-link");
expect(link).not.toBeNull();
// Text before and after the link
expect(container.textContent).toContain("before");
expect(container.textContent).toContain("after");
});
it("keeps a balanced trailing paren that is part of the URL", () => {
const url = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
const fragment = renderMentions(url);
container.appendChild(fragment);
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(link.getAttribute("href")).toBe(url);
expect(link.textContent).toBe(url);
// No stray ")" left dangling as separate trailing text
expect(container.textContent).toBe(url);
});
it("still strips a genuinely unbalanced trailing paren used as sentence punctuation", () => {
const fragment = renderMentions("(see https://example.com/page)");
container.appendChild(fragment);
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(link.getAttribute("href")).toBe("https://example.com/page");
expect(container.textContent).toBe("(see https://example.com/page)");
});
});
// ---------------------------------------------------------------------------
// renderUrlEmbeds (via renderMessage)
// ---------------------------------------------------------------------------
describe("renderUrlEmbeds — embed routing via renderMessage", () => {
it("produces a .msg-image element for a direct image URL instead of a generic link card", () => {
const url = "https://static.example.com/hero.png";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-image")).not.toBeNull();
expect(container.querySelector(".msg-embed-link")).toBeNull();
ac.abort();
});
it("does not duplicate embeds for the same URL appearing twice in content", () => {
const url = "https://example.com/img.gif";
const msg = makeMessage({ content: `${url} and again ${url}` });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
// URL dedup inside renderUrlEmbeds — only one img embed produced
const imgEmbeds = container.querySelectorAll(`.msg-image img[src="${url}"]`);
expect(imgEmbeds.length).toBe(1);
ac.abort();
});
it("skips image URLs that appear inside code blocks", () => {
const url = "https://example.com/hidden.png";
const msg = makeMessage({ content: `\`\`\`${url}\`\`\`` });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
// URL inside a code block must not produce an embed
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
ac.abort();
});
it("skips image URLs inside inline code", () => {
const url = "https://example.com/inline.png";
const msg = makeMessage({ content: `Look at \`${url}\`` });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
ac.abort();
});
it("renders multiple different image URLs as separate .msg-image embeds", () => {
const url1 = "https://example.com/a.png";
const url2 = "https://example.com/b.gif";
const msg = makeMessage({ content: `${url1} and ${url2}` });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(`img[src="${url1}"]`)).not.toBeNull();
expect(container.querySelector(`img[src="${url2}"]`)).not.toBeNull();
ac.abort();
});
it("renders a generic link card for a plain https URL with no image extension", () => {
const url = "https://example.com/some-article";
const msg = makeMessage({ content: url });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
ac.abort();
});
it("produces no embeds for a message with no URLs", () => {
const msg = makeMessage({ content: "just plain text, no links" });
const ac = new AbortController();
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
container.appendChild(el);
expect(container.querySelector(".msg-embed")).toBeNull();
expect(container.querySelector(".msg-image")).toBeNull();
ac.abort();
});
});
});
// ─── Phase 6: display names and avatars on message rows ──────────────────────
describe("message row author identity", () => {
beforeEach(() => {
resetStores();
});
afterEach(() => {
resetStores();
});
it("renders the display name from the member store, keeping the username as a title", () => {
membersStore.setState((prev) => ({
...prev,
members: new Map([
[
10,
{
id: 10,
username: "alice",
displayName: "Alice A.",
avatar: null,
role: "member",
status: "online" as const,
},
],
]),
}));
const el = renderMessage(
makeMessage({ user: { id: 10, username: "alice", avatar: null } }),
false,
[],
makeOpts(),
new AbortController().signal,
);
const author = el.querySelector(".msg-author");
expect(author?.textContent).toBe("Alice A.");
// The handle you would @mention is one hover away.
expect(author?.getAttribute("title")).toBe("alice");
// And the letter follows the rendered name.
expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("A");
});
it("prefers the live member store over the identity frozen into the payload", () => {
// A rename arrives as a user_update and patches the member store; the
// messages already on screen still carry the old name in their payload.
membersStore.setState((prev) => ({
...prev,
members: new Map([
[
10,
{
id: 10,
username: "renamed",
displayName: null,
avatar: null,
role: "member",
status: "online" as const,
},
],
]),
}));
const el = renderMessage(
makeMessage({ user: { id: 10, username: "old-name", avatar: null } }),
false,
[],
makeOpts(),
new AbortController().signal,
);
expect(el.querySelector(".msg-author")?.textContent).toBe("renamed");
});
it("falls back to the payload for an author who is not in the member list", () => {
const el = renderMessage(
makeMessage({
user: { id: 99, username: "ghost", avatar: null, display_name: "Ghosty" },
}),
false,
[],
makeOpts(),
new AbortController().signal,
);
expect(el.querySelector(".msg-author")?.textContent).toBe("Ghosty");
expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("G");
});
});