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 found inside an element. */ function fireImgLoad(parent: HTMLElement): void { const img = parent.querySelector("img") as HTMLImageElement | null; if (img === null) throw new Error("No found"); img.dispatchEvent(new Event("load")); } /** Simulate image error event on the first found inside an element. */ function fireImgError(parent: HTMLElement): void { const img = parent.querySelector("img") as HTMLImageElement | null; if (img === null) throw new Error("No 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 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( "", "https://www.youtube.com/watch?v=", ); 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) => 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"); }); }); }); });