Files
OwnCord/Client/tauri-client/tests/unit/media.test.ts
T
J3vbandClaude 5202e3fe1e fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)

OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.

OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.

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

A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.

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

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

normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.

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

* fix(admin): 1 defect(s) (OC-0203)

Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.

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

* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)

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

* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)

OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.

OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).

OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.

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

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

The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.

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

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

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

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

rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.

* fix(client): 2 defect(s) (OC-0220, OC-0224)

dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.

updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.

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

Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.

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

handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.

* fix(mentions): 1 defect(s) (OC-0223)

* fix(admin): 1 defect(s) (OC-0225)

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

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

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

Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.

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

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

Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.

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

notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).

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

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

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

* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

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

Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.

* fix(dm): 1 defect(s) (OC-0199)

handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.

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

vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.

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

* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful

The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.

The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.

* fix(dm): 1 defect(s) (OC-0194)

Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.

The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.

Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.

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

* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed

Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.

Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.

OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.

* docs: record the dm_channel_open emission on 1:1 DM creation

POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.

The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 20:45:30 +02:00

1470 lines
52 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Hoisted mocks — must be set up before any import that references them.
// ---------------------------------------------------------------------------
const { fetchMock, observeMediaMock, loadPrefMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
observeMediaMock: vi.fn(),
loadPrefMock: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-http", () => ({
fetch: fetchMock,
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/media-visibility", () => ({
observeMedia: observeMediaMock,
}));
vi.mock("@lib/icons", () => ({
createIcon: (name: string, size: number) => {
const el = document.createElement("span");
el.setAttribute("data-icon", name);
el.setAttribute("data-size", String(size));
return el;
},
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: loadPrefMock,
}));
vi.mock("../../src/components/message-list/attachments", () => ({
isSafeUrl: (url: string) => {
try {
const parsed = new URL(url, "https://placeholder");
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
},
}));
vi.mock("../../src/components/message-list/embeds", () => ({
renderGenericLinkPreview: (url: string) => {
const el = document.createElement("div");
el.className = "msg-embed-link";
el.textContent = url;
return el;
},
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import {
extractYouTubeId,
isDirectImageUrl,
renderInlineImage,
renderYouTubeEmbed,
openImageLightbox,
closeActiveLightbox,
extractUrls,
renderUrlEmbeds,
clearMediaCaches,
} from "../../src/components/message-list/media";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function oembedResponse(title: string) {
return {
ok: true,
json: vi.fn().mockResolvedValue({ title }),
};
}
function oembedFail() {
return {
ok: false,
json: vi.fn().mockResolvedValue(null),
};
}
/**
* media.ts caches showEmbeds/inlineMedia/showLinkPreviews/animateGifs at
* module level and re-reads them on "owncord:pref-change" (the event savePref
* dispatches). After changing loadPrefMock, dispatch those events so the
* module's cached values pick up the new mock implementation.
*/
function syncPrefCache(): void {
for (const key of ["showEmbeds", "inlineMedia", "showLinkPreviews", "animateGifs"]) {
window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } }));
}
}
/** Simulate image load event on the first <img> found inside an element. */
function fireImgLoad(parent: HTMLElement): void {
const img = parent.querySelector("img") as HTMLImageElement | null;
if (img === null) throw new Error("No <img> found");
img.dispatchEvent(new Event("load"));
}
/** Simulate image error event on the first <img> found inside an element. */
function fireImgError(parent: HTMLElement): void {
const img = parent.querySelector("img") as HTMLImageElement | null;
if (img === null) throw new Error("No <img> found");
img.dispatchEvent(new Event("error"));
}
/** Create a MouseEvent with specified client coordinates. */
function mouseEvent(
type: string,
opts: { clientX?: number; clientY?: number; deltaY?: number } = {},
): MouseEvent {
return new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: opts.clientX ?? 0,
clientY: opts.clientY ?? 0,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("media.ts", () => {
beforeEach(() => {
fetchMock.mockReset();
observeMediaMock.mockReset();
loadPrefMock.mockReset();
loadPrefMock.mockImplementation((_key: string, fallback: unknown) => fallback);
syncPrefCache();
clearMediaCaches();
document.body.innerHTML = "";
});
afterEach(() => {
// Clean up any lightboxes left on body
document.querySelectorAll(".image-lightbox").forEach((el) => el.remove());
document.body.innerHTML = "";
});
// =========================================================================
// extractYouTubeId
// =========================================================================
describe("extractYouTubeId", () => {
it("extracts ID from youtube.com/watch?v=ID", () => {
expect(extractYouTubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});
it("extracts ID from youtube.com (no www) /watch?v=ID", () => {
expect(extractYouTubeId("https://youtube.com/watch?v=abc123")).toBe("abc123");
});
it("extracts ID from youtu.be/ID", () => {
expect(extractYouTubeId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});
it("returns null for youtu.be with empty path", () => {
expect(extractYouTubeId("https://youtu.be/")).toBeNull();
});
it("extracts ID from youtube.com/embed/ID", () => {
expect(extractYouTubeId("https://www.youtube.com/embed/abc123")).toBe("abc123");
});
it("extracts ID from youtube.com (no www) /embed/ID", () => {
expect(extractYouTubeId("https://youtube.com/embed/xyz789")).toBe("xyz789");
});
it("returns null for youtube.com/embed/ with empty ID", () => {
expect(extractYouTubeId("https://www.youtube.com/embed/")).toBeNull();
});
it("extracts ID from youtube.com/shorts/ID", () => {
expect(extractYouTubeId("https://www.youtube.com/shorts/shortId1")).toBe("shortId1");
});
it("extracts ID from youtube.com (no www) /shorts/ID", () => {
expect(extractYouTubeId("https://youtube.com/shorts/shortId2")).toBe("shortId2");
});
it("returns null for youtube.com/shorts/ with empty ID", () => {
expect(extractYouTubeId("https://www.youtube.com/shorts/")).toBeNull();
});
it("returns null for non-YouTube URLs", () => {
expect(extractYouTubeId("https://example.com/watch?v=abc")).toBeNull();
});
it("returns null for invalid URL", () => {
expect(extractYouTubeId("not a url at all")).toBeNull();
});
it("returns null when youtube.com/watch has no v parameter", () => {
expect(extractYouTubeId("https://www.youtube.com/watch")).toBeNull();
});
it("returns null for youtube.com with non-watch path", () => {
expect(extractYouTubeId("https://www.youtube.com/channel/abc")).toBeNull();
});
});
// =========================================================================
// isDirectImageUrl
// =========================================================================
describe("isDirectImageUrl", () => {
it.each([".gif", ".png", ".jpg", ".jpeg", ".webp"])("returns true for %s extension", (ext) => {
expect(isDirectImageUrl(`https://example.com/photo${ext}`)).toBe(true);
});
it("returns true for uppercase extensions", () => {
expect(isDirectImageUrl("https://example.com/PHOTO.PNG")).toBe(true);
});
it("returns false for non-image extensions", () => {
expect(isDirectImageUrl("https://example.com/doc.pdf")).toBe(false);
});
it("returns false for URLs without extensions", () => {
expect(isDirectImageUrl("https://example.com/page")).toBe(false);
});
it("returns false for invalid URLs", () => {
expect(isDirectImageUrl("not-a-url")).toBe(false);
});
});
// =========================================================================
// renderInlineImage
// =========================================================================
describe("renderInlineImage", () => {
it("creates a div.msg-image wrapper with an <img> inside", () => {
const wrap = renderInlineImage("https://example.com/photo.jpg");
expect(wrap.classList.contains("msg-image")).toBe(true);
const img = wrap.querySelector("img");
expect(img).not.toBeNull();
expect(img!.getAttribute("src")).toBe("https://example.com/photo.jpg");
});
it("sets default min-height of 200px when no cached height", () => {
const wrap = renderInlineImage("https://example.com/new.jpg");
expect(wrap.style.minHeight).toBe("200px");
});
it("uses cached height for subsequent renders of the same URL", () => {
const url = "https://example.com/cached.jpg";
// First render: trigger load to cache height
const wrap1 = renderInlineImage(url);
document.body.appendChild(wrap1);
// Simulate offsetHeight by defining the property
Object.defineProperty(wrap1, "offsetHeight", { value: 150, configurable: true });
fireImgLoad(wrap1);
// Second render should use cached height
const wrap2 = renderInlineImage(url);
expect(wrap2.style.minHeight).toBe("150px");
});
it("clears min-height on successful image load", () => {
const wrap = renderInlineImage("https://example.com/load.png");
document.body.appendChild(wrap);
Object.defineProperty(wrap, "offsetHeight", { value: 100, configurable: true });
fireImgLoad(wrap);
expect(wrap.style.minHeight).toBe("");
});
it("clears min-height on image error", () => {
const wrap = renderInlineImage("https://example.com/broken.png");
document.body.appendChild(wrap);
fireImgError(wrap);
expect(wrap.style.minHeight).toBe("");
});
it("does not cache height of 0", () => {
const url = "https://example.com/zero-height.png";
const wrap = renderInlineImage(url);
document.body.appendChild(wrap);
Object.defineProperty(wrap, "offsetHeight", { value: 0, configurable: true });
fireImgLoad(wrap);
// Second render should still use default 200px because 0 was not cached
const wrap2 = renderInlineImage(url);
expect(wrap2.style.minHeight).toBe("200px");
});
it("adds crossorigin attribute for GIF URLs", () => {
const wrap = renderInlineImage("https://example.com/anim.gif");
const img = wrap.querySelector("img")!;
expect(img.getAttribute("crossorigin")).toBe("anonymous");
});
it("does not add crossorigin attribute for non-GIF URLs", () => {
const wrap = renderInlineImage("https://example.com/photo.png");
const img = wrap.querySelector("img")!;
expect(img.hasAttribute("crossorigin")).toBe(false);
});
it("calls observeMedia for GIF after load (animateGifs enabled)", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => {
if (key === "animateGifs") return true;
return fallback;
});
syncPrefCache();
const url = "https://example.com/animated.gif";
const wrap = renderInlineImage(url);
document.body.appendChild(wrap);
const img = wrap.querySelector("img")!;
// Fire load - the second "load" listener (GIF-specific) should call observeMedia
img.dispatchEvent(new Event("load"));
expect(observeMediaMock).toHaveBeenCalledWith(img, url, wrap, false);
});
it("calls observeMedia with startFrozen=true when animateGifs is disabled", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => {
if (key === "animateGifs") return false;
return fallback;
});
syncPrefCache();
const url = "https://example.com/frozen.gif";
const wrap = renderInlineImage(url);
document.body.appendChild(wrap);
const img = wrap.querySelector("img")!;
img.dispatchEvent(new Event("load"));
expect(observeMediaMock).toHaveBeenCalledWith(img, url, wrap, true);
});
it("does not call observeMedia for non-GIF images", () => {
const wrap = renderInlineImage("https://example.com/photo.png");
document.body.appendChild(wrap);
fireImgLoad(wrap);
expect(observeMediaMock).not.toHaveBeenCalled();
});
it("opens lightbox on image click", () => {
const wrap = renderInlineImage("https://example.com/click.jpg");
document.body.appendChild(wrap);
const img = wrap.querySelector("img")!;
img.click();
const lightbox = document.body.querySelector(".image-lightbox");
expect(lightbox).not.toBeNull();
});
});
// =========================================================================
// Image height cache eviction
// =========================================================================
describe("image height cache eviction", () => {
it("evicts the oldest entry when cache exceeds MAX_IMAGE_HEIGHT_CACHE", () => {
// Fill the cache with 500 entries then add one more
for (let i = 0; i < 501; i++) {
const url = `https://example.com/img-${i}.jpg`;
const wrap = renderInlineImage(url);
document.body.appendChild(wrap);
Object.defineProperty(wrap, "offsetHeight", { value: 100 + i, configurable: true });
fireImgLoad(wrap);
}
// The first URL should have been evicted - renders with default 200px
const wrap = renderInlineImage("https://example.com/img-0.jpg");
expect(wrap.style.minHeight).toBe("200px");
// The second URL should still be cached
const wrap2 = renderInlineImage("https://example.com/img-1.jpg");
expect(wrap2.style.minHeight).toBe("101px");
});
});
// =========================================================================
// isGifUrl (tested indirectly through renderInlineImage)
// =========================================================================
describe("isGifUrl (indirect)", () => {
it("identifies .gif extension as GIF", () => {
const wrap = renderInlineImage("https://example.com/img.gif");
expect(wrap.querySelector("img")!.getAttribute("crossorigin")).toBe("anonymous");
});
it("does not identify .png as GIF", () => {
const wrap = renderInlineImage("https://example.com/img.png");
expect(wrap.querySelector("img")!.hasAttribute("crossorigin")).toBe(false);
});
it("handles malformed URL gracefully in GIF check", () => {
// isGifUrl catches URL parse errors — this should not throw
// We test by providing a URL that fails new URL() but still works
// for image rendering. Since renderInlineImage doesn't validate URLs,
// it just passes through. The isGifUrl uses a placeholder base.
const wrap = renderInlineImage("https://example.com/image.GIF");
// .GIF (uppercase) should be detected as GIF because pathname.toLowerCase()
expect(wrap.querySelector("img")!.getAttribute("crossorigin")).toBe("anonymous");
});
});
// =========================================================================
// renderYouTubeEmbed
// =========================================================================
describe("renderYouTubeEmbed", () => {
it("renders a YouTube embed with thumbnail and play button", () => {
fetchMock.mockResolvedValue(oembedResponse("Test Video"));
const embed = renderYouTubeEmbed("abc123", "https://www.youtube.com/watch?v=abc123");
document.body.appendChild(embed);
expect(embed.classList.contains("msg-embed-youtube")).toBe(true);
expect(embed.querySelector(".msg-embed-host")?.textContent).toBe("YouTube");
expect(embed.querySelector(".msg-embed-thumb")).not.toBeNull();
expect(embed.querySelector(".msg-embed-play")).not.toBeNull();
});
it("shows 'Loading...' while fetching the title", () => {
fetchMock.mockReturnValue(new Promise(() => {})); // Never resolves
const embed = renderYouTubeEmbed("pending", "https://www.youtube.com/watch?v=pending");
const title = embed.querySelector(".msg-embed-yt-title");
expect(title?.textContent).toBe("Loading...");
});
it("updates title when oembed fetch succeeds", async () => {
fetchMock.mockResolvedValue(oembedResponse("My Great Video"));
const embed = renderYouTubeEmbed("success1", "https://www.youtube.com/watch?v=success1");
document.body.appendChild(embed);
const title = embed.querySelector(".msg-embed-yt-title")!;
await vi.waitFor(() => {
expect(title.textContent).toBe("My Great Video");
});
});
it("caches the title and reuses it on subsequent renders", async () => {
fetchMock.mockResolvedValue(oembedResponse("Cached Title"));
const embed1 = renderYouTubeEmbed("cached1", "https://www.youtube.com/watch?v=cached1");
document.body.appendChild(embed1);
await vi.waitFor(() => {
expect(embed1.querySelector(".msg-embed-yt-title")?.textContent).toBe("Cached Title");
});
// Second render should use cache
const embed2 = renderYouTubeEmbed("cached1", "https://www.youtube.com/watch?v=cached1");
expect(embed2.querySelector(".msg-embed-yt-title")?.textContent).toBe("Cached Title");
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch
});
it("falls back to 'YouTube Video' when oembed returns no title", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({}),
});
const embed = renderYouTubeEmbed("notitle", "https://www.youtube.com/watch?v=notitle");
document.body.appendChild(embed);
await vi.waitFor(() => {
expect(embed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("falls back to 'YouTube Video' when oembed returns null", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(null),
});
const embed = renderYouTubeEmbed("nulldata", "https://www.youtube.com/watch?v=nulldata");
document.body.appendChild(embed);
await vi.waitFor(() => {
expect(embed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("falls back to 'YouTube Video' when oembed fetch fails (non-ok)", async () => {
fetchMock.mockResolvedValue(oembedFail());
const embed = renderYouTubeEmbed("fail1", "https://www.youtube.com/watch?v=fail1");
document.body.appendChild(embed);
await vi.waitFor(() => {
expect(embed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("falls back to 'YouTube Video' when oembed fetch rejects (network error)", async () => {
fetchMock.mockRejectedValue(new Error("Network error"));
const embed = renderYouTubeEmbed("neterr", "https://www.youtube.com/watch?v=neterr");
document.body.appendChild(embed);
await vi.waitFor(() => {
expect(embed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("renders fallback link for invalid video ID (XSS prevention)", () => {
const embed = renderYouTubeEmbed(
"<script>alert(1)</script>",
"https://www.youtube.com/watch?v=<script>alert(1)</script>",
);
document.body.appendChild(embed);
// Should render a plain link fallback, not YouTube embed
expect(embed.classList.contains("msg-embed-youtube")).toBe(false);
expect(embed.classList.contains("msg-embed")).toBe(true);
const link = embed.querySelector("a");
expect(link).not.toBeNull();
});
it("replaces thumbnail with iframe on click", () => {
fetchMock.mockResolvedValue(oembedResponse("Click Test"));
const embed = renderYouTubeEmbed("click1", "https://www.youtube.com/watch?v=click1");
document.body.appendChild(embed);
const thumbWrap = embed.querySelector(".msg-embed-yt-player")!;
expect(thumbWrap.querySelector("img")).not.toBeNull();
expect(thumbWrap.querySelector("iframe")).toBeNull();
(thumbWrap as HTMLElement).click();
expect(thumbWrap.querySelector("iframe")).not.toBeNull();
expect(thumbWrap.querySelector("img")).toBeNull();
const iframe = thumbWrap.querySelector("iframe")!;
expect(iframe.src).toContain("click1");
expect(iframe.src).toContain("autoplay=1");
expect(iframe.getAttribute("allowfullscreen")).toBe("");
});
it("only replaces thumbnail once (click handler is {once: true})", () => {
fetchMock.mockResolvedValue(oembedResponse("Once Test"));
const embed = renderYouTubeEmbed("once1", "https://www.youtube.com/watch?v=once1");
document.body.appendChild(embed);
const thumbWrap = embed.querySelector(".msg-embed-yt-player") as HTMLElement;
thumbWrap.click();
const iframe = thumbWrap.querySelector("iframe")!;
const originalSrc = iframe.src;
// Second click should not replace iframe again
thumbWrap.click();
expect(thumbWrap.querySelector("iframe")!.src).toBe(originalSrc);
});
it("evicts oldest YouTube title cache entry when exceeding limit", async () => {
// Fill ytTitleCache to its max (200) and verify eviction
for (let i = 0; i < 201; i++) {
fetchMock.mockResolvedValueOnce(oembedResponse(`Title ${i}`));
const embed = renderYouTubeEmbed(`vid${i}`, `https://www.youtube.com/watch?v=vid${i}`);
document.body.appendChild(embed);
}
await vi.waitFor(() => {
// Last video should have its title
const last = document.body.querySelector(`[href="https://www.youtube.com/watch?v=vid200"]`);
expect(last?.textContent).toBe("Title 200");
});
});
it("evicts oldest cache entry on catch branch too", async () => {
// First fill 200 entries via successful fetches
for (let i = 0; i < 200; i++) {
fetchMock.mockResolvedValueOnce(oembedResponse(`Title ${i}`));
const embed = renderYouTubeEmbed(`errv${i}`, `https://www.youtube.com/watch?v=errv${i}`);
document.body.appendChild(embed);
}
await vi.waitFor(() => {
const link = document.body.querySelector(
`[href="https://www.youtube.com/watch?v=errv199"]`,
);
expect(link?.textContent).toBe("Title 199");
});
// Now add one more that fails (triggers catch branch)
fetchMock.mockRejectedValueOnce(new Error("fail"));
const failEmbed = renderYouTubeEmbed("errv200", "https://www.youtube.com/watch?v=errv200");
document.body.appendChild(failEmbed);
await vi.waitFor(() => {
expect(failEmbed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("uses fallback title when cache generation changes during successful fetch", async () => {
let resolveFetch: ((value: ReturnType<typeof oembedResponse>) => void) | null = null;
fetchMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
const embed = renderYouTubeEmbed("gen1", "https://www.youtube.com/watch?v=gen1");
document.body.appendChild(embed);
const title = embed.querySelector(".msg-embed-yt-title")!;
expect(title.textContent).toBe("Loading...");
// Clear caches (bumps generation)
clearMediaCaches();
// Resolve the fetch after cache clear
resolveFetch!(oembedResponse("Stale Title"));
await vi.waitFor(() => {
expect(title.textContent).toBe("YouTube Video");
});
});
it("uses fallback title when cache generation changes during failed fetch", async () => {
let rejectFetch: ((reason: Error) => void) | null = null;
fetchMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFetch = reject;
}),
);
const embed = renderYouTubeEmbed("gen2", "https://www.youtube.com/watch?v=gen2");
document.body.appendChild(embed);
clearMediaCaches();
rejectFetch!(new Error("Network error"));
await vi.waitFor(() => {
expect(embed.querySelector(".msg-embed-yt-title")?.textContent).toBe("YouTube Video");
});
});
it("sets thumbnail image src and alt correctly", () => {
fetchMock.mockResolvedValue(oembedResponse("Thumb Test"));
const embed = renderYouTubeEmbed("thumb1", "https://www.youtube.com/watch?v=thumb1");
const thumb = embed.querySelector(".msg-embed-thumb") as HTMLImageElement;
expect(thumb.src).toContain("thumb1");
expect(thumb.getAttribute("alt")).toBe("YouTube video");
expect(thumb.getAttribute("loading")).toBe("lazy");
});
it("title link has correct href and target attributes", () => {
fetchMock.mockResolvedValue(oembedResponse("Link Test"));
const originalUrl = "https://www.youtube.com/watch?v=link1";
const embed = renderYouTubeEmbed("link1", originalUrl);
const titleLink = embed.querySelector(".msg-embed-yt-title") as HTMLAnchorElement;
expect(titleLink.getAttribute("href")).toBe(originalUrl);
expect(titleLink.getAttribute("target")).toBe("_blank");
expect(titleLink.getAttribute("rel")).toBe("noopener noreferrer");
});
});
// =========================================================================
// openImageLightbox
// =========================================================================
describe("openImageLightbox", () => {
it("appends a lightbox overlay to document.body", () => {
openImageLightbox("https://example.com/test.png", "Test image");
const lightbox = document.body.querySelector(".image-lightbox");
expect(lightbox).not.toBeNull();
});
it("contains an image with the provided src and alt", () => {
openImageLightbox("https://example.com/pic.jpg", "My photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLImageElement;
expect(img).not.toBeNull();
expect(img.getAttribute("src")).toBe("https://example.com/pic.jpg");
expect(img.getAttribute("alt")).toBe("My photo");
});
it("contains a close button", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const closeBtn = document.body.querySelector(".image-lightbox-close");
expect(closeBtn).not.toBeNull();
});
it("closes on close button click", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const closeBtn = document.body.querySelector(".image-lightbox-close") as HTMLElement;
closeBtn.click();
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("closes on overlay background click", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const overlay = document.body.querySelector(".image-lightbox") as HTMLElement;
// Click the overlay itself (not a child)
overlay.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("does not close when clicking on the image", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
// Simulate a click where mouse didn't move (dx=0, dy=0)
img.dispatchEvent(new MouseEvent("mousedown", { clientX: 100, clientY: 100, bubbles: true }));
img.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 100, bubbles: true }));
// The image click toggles zoom, not close. Lightbox should still exist.
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
});
it("closes on Escape key", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("zooms in on + key", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
document.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true }));
// After zoom, transform should contain scale > 1
expect(img.style.transform).toContain("scale(");
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
const scale = parseFloat(scaleMatch![1]!);
expect(scale).toBeGreaterThan(1);
});
it("zooms in on = key (same as +)", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
document.dispatchEvent(new KeyboardEvent("keydown", { key: "=", bubbles: true }));
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
const scale = parseFloat(scaleMatch![1]!);
expect(scale).toBeGreaterThan(1);
});
it("zooms out on - key", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
document.dispatchEvent(new KeyboardEvent("keydown", { key: "-", bubbles: true }));
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
const scale = parseFloat(scaleMatch![1]!);
expect(scale).toBeLessThan(1);
});
it("resets zoom on 0 key", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
// Zoom in first
document.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true }));
document.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true }));
// Reset
document.dispatchEvent(new KeyboardEvent("keydown", { key: "0", bubbles: true }));
expect(img.style.transform).toContain("scale(1)");
expect(img.style.transform).toContain("translate(0px, 0px)");
});
it("toggles zoom on image click (zoom in when not zoomed)", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
// Mock getBoundingClientRect for the click zoom calculation
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Simulate mousedown then click at same position (no drag)
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
img.dispatchEvent(
new MouseEvent("click", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
// Should zoom to scale 3
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
expect(parseFloat(scaleMatch![1]!)).toBe(3);
});
it("toggles zoom on image click (zoom out when zoomed in)", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Zoom in first
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
img.dispatchEvent(
new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }),
);
// Now click again to zoom out (scale > 1.1 so it resets)
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
img.dispatchEvent(
new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }),
);
expect(img.style.transform).toContain("scale(1)");
});
it("does not toggle zoom when mouse moves during click (drag gesture)", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
// Mousedown at one position, click at another (moved > 5px)
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true,
}),
);
img.dispatchEvent(
new MouseEvent("click", { clientX: 120, clientY: 100, bubbles: true, cancelable: true }),
);
// Should remain at scale(1) because dx=20 > 5
expect(img.style.transform).toBe("");
});
it("supports panning when zoomed in", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
const overlay = document.body.querySelector(".image-lightbox") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Zoom in first
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
img.dispatchEvent(
new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }),
);
// Now start panning (mousedown while zoomed)
img.dispatchEvent(
new MouseEvent("mousedown", {
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
expect(overlay.classList.contains("dragging")).toBe(true);
// Move mouse
document.dispatchEvent(
new MouseEvent("mousemove", { clientX: 250, clientY: 200, bubbles: true }),
);
// Transform should reflect pan offset
expect(img.style.transform).toContain("translate(");
// Mouse up ends dragging
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(overlay.classList.contains("dragging")).toBe(false);
});
it("mouseup does nothing when not dragging", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const overlay = document.body.querySelector(".image-lightbox") as HTMLElement;
// mouseup without prior drag should not throw or change anything
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(overlay.classList.contains("dragging")).toBe(false);
});
it("mousemove does nothing when not dragging", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
// mousemove without prior drag should not alter transform
document.dispatchEvent(
new MouseEvent("mousemove", { clientX: 300, clientY: 300, bubbles: true }),
);
expect(img.style.transform).toBe("");
});
it("handles wheel zoom in", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Scroll up (negative deltaY = zoom in)
const wheelEvent = new WheelEvent("wheel", {
deltaY: -100,
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
});
imgWrap.dispatchEvent(wheelEvent);
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
expect(parseFloat(scaleMatch![1]!)).toBeGreaterThan(1);
});
it("handles wheel zoom out", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Scroll down (positive deltaY = zoom out)
const wheelEvent = new WheelEvent("wheel", {
deltaY: 100,
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
});
imgWrap.dispatchEvent(wheelEvent);
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(scaleMatch).not.toBeNull();
expect(parseFloat(scaleMatch![1]!)).toBeLessThan(1);
});
it("clamps wheel zoom to min scale 0.5", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Zoom out many times
for (let i = 0; i < 20; i++) {
imgWrap.dispatchEvent(
new WheelEvent("wheel", {
deltaY: 100,
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
}
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(parseFloat(scaleMatch![1]!)).toBeGreaterThanOrEqual(0.5);
});
it("clamps wheel zoom to max scale 10", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement;
vi.spyOn(img, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 400,
height: 300,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
});
// Zoom in many times
for (let i = 0; i < 50; i++) {
imgWrap.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -100,
clientX: 200,
clientY: 150,
bubbles: true,
cancelable: true,
}),
);
}
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(parseFloat(scaleMatch![1]!)).toBeLessThanOrEqual(10);
});
it("clamps keyboard zoom to max scale 10", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
for (let i = 0; i < 50; i++) {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true }));
}
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(parseFloat(scaleMatch![1]!)).toBeLessThanOrEqual(10);
});
it("clamps keyboard zoom to min scale 0.5", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
const img = document.body.querySelector(".image-lightbox img") as HTMLElement;
for (let i = 0; i < 50; i++) {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "-", bubbles: true }));
}
const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/);
expect(parseFloat(scaleMatch![1]!)).toBeGreaterThanOrEqual(0.5);
});
it("closes previous lightbox when opening a new one", () => {
openImageLightbox("https://example.com/first.png", "First");
expect(document.body.querySelectorAll(".image-lightbox").length).toBe(1);
openImageLightbox("https://example.com/second.png", "Second");
// Should have replaced the first one
const lightboxes = document.body.querySelectorAll(".image-lightbox");
expect(lightboxes.length).toBe(1);
const img = lightboxes[0]!.querySelector("img") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("https://example.com/second.png");
});
it("cleans up document-level listeners on close", () => {
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
openImageLightbox("https://example.com/cleanup.png", "Cleanup");
// The document-level keydown listener is registered with the lightbox's
// AbortController signal; capture it to prove the controller is aborted
// on close (rather than just re-dispatching events and asserting nothing).
const keydownCall = addEventListenerSpy.mock.calls.find(
(call) => call[0] === "keydown" && typeof call[2] === "object" && call[2] !== null,
);
const signal = (keydownCall?.[2] as AddEventListenerOptions | undefined)?.signal;
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal!.aborted).toBe(false);
const closeBtn = document.body.querySelector(".image-lightbox-close") as HTMLElement;
closeBtn.click();
expect(signal!.aborted).toBe(true);
addEventListenerSpy.mockRestore();
});
});
// =========================================================================
// closeActiveLightbox (B6-15: lightbox survives logout/page swap)
// =========================================================================
describe("closeActiveLightbox", () => {
it("closes the active lightbox and removes it from the DOM", () => {
openImageLightbox("https://example.com/pic.jpg", "Photo");
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
closeActiveLightbox();
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("is a no-op when no lightbox is open", () => {
expect(() => closeActiveLightbox()).not.toThrow();
});
});
// =========================================================================
// extractUrls
// =========================================================================
describe("extractUrls", () => {
it("extracts a single URL", () => {
const urls = extractUrls("Check out https://example.com");
expect(urls).toEqual(["https://example.com"]);
});
it("extracts multiple URLs", () => {
const urls = extractUrls("Visit https://a.com and https://b.com");
expect(urls).toEqual(["https://a.com", "https://b.com"]);
});
it("returns empty array for text without URLs", () => {
const urls = extractUrls("Just plain text here");
expect(urls).toEqual([]);
});
it("skips URLs inside code blocks", () => {
const urls = extractUrls("```\nhttps://hidden.com\n```");
expect(urls).toEqual([]);
});
it("skips URLs inside inline code", () => {
const urls = extractUrls("See `https://hidden.com` here");
expect(urls).toEqual([]);
});
it("extracts URLs outside code blocks but not inside", () => {
const urls = extractUrls("https://visible.com ```https://hidden.com```");
expect(urls).toEqual(["https://visible.com"]);
});
it("extracts http and https URLs", () => {
const urls = extractUrls("http://insecure.com https://secure.com");
expect(urls).toEqual(["http://insecure.com", "https://secure.com"]);
});
it("strips sentence-ending trailing punctuation, matching the linkifier", () => {
const urls = extractUrls("Nice pic https://cdn.example.com/a.png.");
expect(urls).toEqual(["https://cdn.example.com/a.png"]);
});
it("strips a wrapping close-paren but keeps a balanced one from the URL itself", () => {
const wrapped = extractUrls("(https://cdn.example.com/a.png)");
expect(wrapped).toEqual(["https://cdn.example.com/a.png"]);
const balanced = extractUrls(
"See https://en.wikipedia.org/wiki/Rust_(programming_language) for details",
);
expect(balanced).toEqual(["https://en.wikipedia.org/wiki/Rust_(programming_language)"]);
});
it("strips trailing punctuation from a YouTube link so it resolves to a valid video id", () => {
const urls = extractUrls("Check https://youtu.be/dQw4w9WgXcQ.");
expect(urls).toEqual(["https://youtu.be/dQw4w9WgXcQ"]);
});
});
// =========================================================================
// renderUrlEmbeds
// =========================================================================
describe("renderUrlEmbeds", () => {
it("renders YouTube embed for YouTube URLs", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
fetchMock.mockResolvedValue(oembedResponse("YT Video"));
const fragment = renderUrlEmbeds("Check this: https://www.youtube.com/watch?v=test1");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-embed-youtube")).not.toBeNull();
});
it("renders inline image for direct image URLs", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
const fragment = renderUrlEmbeds("Image: https://example.com/photo.png");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-image")).not.toBeNull();
});
it("renders generic link preview for non-image, non-YouTube URLs", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
const fragment = renderUrlEmbeds("Check https://example.com/article");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
});
it("deduplicates URLs in the same message", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
const url = "https://example.com/photo.jpg";
const fragment = renderUrlEmbeds(`${url} and ${url}`);
const container = document.createElement("div");
container.appendChild(fragment);
const images = container.querySelectorAll(".msg-image");
expect(images.length).toBe(1);
});
it("skips YouTube embed when showEmbeds is disabled", () => {
loadPrefMock.mockImplementation((key: string, _fallback: unknown) => {
if (key === "showEmbeds") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://www.youtube.com/watch?v=skip1");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-embed-youtube")).toBeNull();
});
it("skips inline images when inlineMedia is disabled", () => {
loadPrefMock.mockImplementation((key: string, _fallback: unknown) => {
if (key === "inlineMedia") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://example.com/photo.png");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-image")).toBeNull();
});
it("skips link previews when showLinkPreviews is disabled", () => {
loadPrefMock.mockImplementation((key: string, _fallback: unknown) => {
if (key === "showLinkPreviews") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://example.com/article");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-embed-link")).toBeNull();
});
it("produces empty fragment when all preferences are disabled", () => {
loadPrefMock.mockReturnValue(false);
syncPrefCache();
const fragment = renderUrlEmbeds(
"https://www.youtube.com/watch?v=abc https://example.com/pic.png https://example.com/page",
);
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.children.length).toBe(0);
});
it("does not render embeds for unsafe URLs", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
// ftp:// is not a safe URL (only http/https)
const fragment = renderUrlEmbeds("ftp://example.com/photo.png");
const container = document.createElement("div");
container.appendChild(fragment);
// ftp URL won't match URL_REGEX (which only matches http/https)
expect(container.children.length).toBe(0);
});
it("renders multiple different embed types in a single message", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
fetchMock.mockResolvedValue(oembedResponse("Video"));
const fragment = renderUrlEmbeds(
"Video: https://www.youtube.com/watch?v=multi1 Image: https://example.com/pic.jpg Article: https://example.com/news",
);
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.querySelector(".msg-embed-youtube")).not.toBeNull();
expect(container.querySelector(".msg-image")).not.toBeNull();
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
});
it("produces empty fragment for message with no URLs", () => {
loadPrefMock.mockImplementation((key: string, fallback: unknown) => fallback);
const fragment = renderUrlEmbeds("Just a plain message with no links");
const container = document.createElement("div");
container.appendChild(fragment);
expect(container.children.length).toBe(0);
});
});
// =========================================================================
// clearMediaCaches
// =========================================================================
describe("clearMediaCaches", () => {
it("clears image height cache", () => {
const url = "https://example.com/clear-test.jpg";
// First render and cache height
const wrap = renderInlineImage(url);
document.body.appendChild(wrap);
Object.defineProperty(wrap, "offsetHeight", { value: 250, configurable: true });
fireImgLoad(wrap);
// Verify cached
const wrap2 = renderInlineImage(url);
expect(wrap2.style.minHeight).toBe("250px");
// Clear caches
clearMediaCaches();
// Should now use default
const wrap3 = renderInlineImage(url);
expect(wrap3.style.minHeight).toBe("200px");
});
it("clears YouTube title cache", async () => {
fetchMock.mockResolvedValueOnce(oembedResponse("Original Title"));
const embed1 = renderYouTubeEmbed("clear1", "https://www.youtube.com/watch?v=clear1");
document.body.appendChild(embed1);
await vi.waitFor(() => {
expect(embed1.querySelector(".msg-embed-yt-title")?.textContent).toBe("Original Title");
});
clearMediaCaches();
// After clearing, should fetch again
fetchMock.mockResolvedValueOnce(oembedResponse("New Title"));
const embed2 = renderYouTubeEmbed("clear1", "https://www.youtube.com/watch?v=clear1");
document.body.appendChild(embed2);
// Should show "Loading..." initially (not cached)
expect(embed2.querySelector(".msg-embed-yt-title")?.textContent).toBe("Loading...");
await vi.waitFor(() => {
expect(embed2.querySelector(".msg-embed-yt-title")?.textContent).toBe("New Title");
});
});
});
});