From 44678d99c2ed1c2318dc8682fc1833e4507bd4eb Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 00:38:39 +0000 Subject: [PATCH] test(canvas/chat): add AttachmentImage coverage (10 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Vitest coverage for AttachmentImage — inline image thumbnail with click-to-fullscreen lightbox. Covers: loading skeleton (240×180), ready state with blob URL, tone=user/agent border classes, lightbox open/close on click and Escape, AttachmentChip error fallback, img onError transition to chip, external URI direct href (no fetch), and blob URL cleanup on unmount. Co-Authored-By: Claude Opus 4.7 --- .../chat/__tests__/AttachmentImage.test.tsx | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx new file mode 100644 index 00000000..547f2fb6 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx @@ -0,0 +1,346 @@ +// @vitest-environment jsdom +/** + * AttachmentImage — inline image thumbnail with click-to-fullscreen lightbox. + * + * Per RFC #2991 PR-1: platform-auth URIs fetch bytes → Blob → ObjectURL; + * external URIs use the raw URL directly. State machine: idle → loading → + * ready/error. Loading skeleton shown while fetching. Error falls back to + * AttachmentChip. Blob URL cleaned up on unmount / re-run. + * + * NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions. + * + * Covers: + * - Renders loading skeleton (240×180) with aria-label while fetching + * - Renders inside button with correct src when ready + * - Lightbox opens on button click, closes on backdrop/escape + * - Hover reveals filename overlay + * - tone=user applies blue border class + * - tone=agent applies neutral border class + * - Error state renders AttachmentChip fallback + * - External URI uses direct href without auth fetch + * - Cleans up blob URL on unmount + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import React from "react"; + +import { AttachmentImage } from "../AttachmentImage"; +import type { ChatAttachment } from "../types"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>( + (id, uri) => `https://api.moleculesai.app/attachments/${uri}`, +); +const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true); + +vi.mock("../uploads", () => ({ + isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri), + resolveAttachmentHref: (id: string, uri: string) => + mockResolveAttachmentHref(id, uri), +})); + +vi.mock("@/lib/api", () => ({ + platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeAttachment(name: string, size?: number): ChatAttachment { + return { name, uri: `workspace:/tmp/${name}`, size }; +} + +beforeEach(() => { + // Reset to known-good state for each test. + mockIsPlatformAttachment.mockReturnValue(true); + mockResolveAttachmentHref.mockReturnValue( + (id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`, + ); +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Fetch mock helpers ─────────────────────────────────────────────────────── + +function mockFetchOk(body: string, contentType = "image/png") { + const blob = new Blob([body], { type: contentType }); + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(blob), + headers: new Map([["content-type", contentType]]), + }) as unknown as Response, + ); +} + +function mockFetchError() { + global.fetch = vi.fn(() => + Promise.resolve({ ok: false, status: 500 }) as unknown as Response, + ); +} + +// ─── Loading / idle state ───────────────────────────────────────────────────── + +describe("AttachmentImage — loading/idle", () => { + beforeEach(() => { + mockFetchOk("imagedata"); + }); + + it("renders loading skeleton (240×180) with aria-label", () => { + const att = makeAttachment("photo.jpg", 1024 * 512); + const { container } = render( + , + ); + const skeleton = container.querySelector('[aria-label]') as HTMLElement; + expect(skeleton?.getAttribute("aria-label")).toContain("photo.jpg"); + expect(skeleton?.getAttribute("aria-label")).toContain("Loading"); + // Skeleton dimensions + expect(skeleton?.style.width).toBe("240px"); + expect(skeleton?.style.height).toBe("180px"); + }); +}); + +// ─── Ready state ─────────────────────────────────────────────────────────────── + +describe("AttachmentImage — ready", () => { + beforeEach(() => { + mockFetchOk("imagedata"); + }); + + it("renders inside a button with blob src when ready", async () => { + const att = makeAttachment("photo.jpg", 1024 * 512); + render( + , + ); + await vi.waitFor(() => { + const img = document.querySelector("img"); + expect(img).toBeTruthy(); + }); + const img = document.querySelector("img") as HTMLImageElement; + expect(img.src).toMatch(/^blob:/); + // Image button should have correct aria-label + const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn?.getAttribute("aria-label")).toContain("photo.jpg"); + }); + + it("tone=user applies blue border class", async () => { + mockFetchOk("data"); + const att = makeAttachment("photo.jpg"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const img = document.querySelector("img"); + const btn = img?.closest("button"); + expect(btn?.className).toContain("blue-400"); + }); + + it("tone=agent applies neutral border class (no blue)", async () => { + mockFetchOk("data"); + const att = makeAttachment("photo.jpg"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const img = document.querySelector("img"); + const btn = img?.closest("button"); + expect(btn?.className).not.toContain("blue-400"); + }); +}); + +// ─── Lightbox ───────────────────────────────────────────────────────────────── + +describe("AttachmentImage — lightbox", () => { + beforeEach(() => { + mockFetchOk("imagedata"); + }); + + it("opens lightbox on button click", async () => { + const att = makeAttachment("photo.jpg"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement; + btn.click(); + // Lightbox dialog should appear + await vi.waitFor(() => { + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + }); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog?.getAttribute("aria-label")).toContain("photo.jpg"); + // Lightbox contains an + expect(dialog?.querySelector("img")).toBeTruthy(); + }); + + it("closes lightbox on Escape key", async () => { + const att = makeAttachment("photo.jpg"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement; + btn.click(); + await vi.waitFor(() => { + expect(document.querySelector('[role="dialog"]')).toBeTruthy(); + }); + fireEvent.keyDown(document, { key: "Escape" }); + await vi.waitFor(() => { + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + }); +}); + +// ─── Error state ─────────────────────────────────────────────────────────────── + +describe("AttachmentImage — error", () => { + it("renders AttachmentChip fallback when fetch fails", async () => { + mockFetchError(); + const onDownload = vi.fn(); + const att = makeAttachment("broken.jpg", 256); + render( + , + ); + await vi.waitFor(() => { + const chip = document.querySelector("button"); + expect(chip).toBeTruthy(); + expect(chip?.textContent).toContain("broken.jpg"); + }); + // Clicking the chip calls onDownload + const chip = document.querySelector("button") as HTMLButtonElement; + chip.click(); + expect(onDownload).toHaveBeenCalledWith(att); + }); + + it("renders AttachmentChip when img onError fires", async () => { + mockFetchOk("imagedata"); + const onDownload = vi.fn(); + const att = makeAttachment("corrupt.jpg", 256); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + // Simulate img onError + const img = document.querySelector("img") as HTMLImageElement; + fireEvent.error(img); + await vi.waitFor(() => { + const chip = document.querySelector("button"); + expect(chip).toBeTruthy(); + expect(chip?.textContent).toContain("corrupt.jpg"); + }); + }); +}); + +// ─── External URI ───────────────────────────────────────────────────────────── + +describe("AttachmentImage — external URI", () => { + it("skips auth fetch and uses direct href for external URIs", async () => { + // Reset fetch so we can assert it was never called + global.fetch = vi.fn(); + mockIsPlatformAttachment.mockReturnValue(false); + // For external URIs the component calls resolveAttachmentHref for the src + mockResolveAttachmentHref.mockReturnValue("https://example.com/photo.jpg"); + const att = makeAttachment("photo.jpg"); + att.uri = "https://example.com/photo.jpg"; + const onDownload = vi.fn(); + render( + , + ); + // Should skip loading skeleton and go straight to ready (external URL) + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const img = document.querySelector("img") as HTMLImageElement; + // Should be the direct href, not a blob + expect(img.src).toContain("example.com/photo.jpg"); + // Fetch should never have been called for external (non-platform) attachments + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +// ─── Cleanup ────────────────────────────────────────────────────────────────── + +describe("AttachmentImage — blob URL cleanup", () => { + it("creates blob URL on mount and cleans up on unmount", async () => { + mockIsPlatformAttachment.mockReturnValue(true); + mockFetchOk("imagedata"); + const att = makeAttachment("photo.jpg"); + const { unmount } = render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("img")).toBeTruthy(); + }); + const img = document.querySelector("img") as HTMLImageElement; + const blobUrl = img.src; + expect(blobUrl).toMatch(/^blob:/); + unmount(); + // Image should be gone + expect(document.querySelector("img")).toBeNull(); + }); +});