import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaPickerModal } from "../../src/components/MediaPickerModal"; import { render } from "../utils/render.tsx"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- // Anchored to the button's exact accessible name. Without anchors this also // matches the hidden file `` and trips // playwright's strict-mode "resolved to N elements" guard. const UPLOAD_BUTTON_REGEX = /^Upload$/; vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { ...actual, fetchMediaList: vi.fn().mockResolvedValue({ items: [ { id: "m1", filename: "photo.jpg", mimeType: "image/jpeg", url: "/media/photo.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-01", }, { id: "m2", filename: "landscape.png", mimeType: "image/png", url: "/media/landscape.png", size: 2048, width: 1200, height: 800, createdAt: "2024-01-02", }, ], }), fetchMediaProviders: vi.fn().mockResolvedValue([]), fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }), uploadMedia: vi.fn().mockResolvedValue({ id: "m3", filename: "new.jpg" }), uploadToProvider: vi.fn().mockResolvedValue({}), updateMedia: vi.fn().mockResolvedValue({}), }; }); function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return {children}; } function renderModal(props: Partial> = {}) { const defaultProps: React.ComponentProps = { open: true, onOpenChange: vi.fn(), onSelect: vi.fn(), ...props, }; return render( , ); } describe("MediaPickerModal", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("displaying items", () => { it("shows media items when open", async () => { const screen = await renderModal({ open: true }); await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); await expect .element(screen.getByRole("option", { name: "landscape.png" })) .toBeInTheDocument(); }); it("shows the modal title", async () => { const screen = await renderModal({ title: "Pick an Image" }); await expect.element(screen.getByText("Pick an Image")).toBeInTheDocument(); }); }); describe("selection", () => { it("single click selects item (highlighted)", async () => { const screen = await renderModal(); const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); // Direct DOM click to bypass inert overlay const btn = option.element().querySelector("button")!; btn.click(); // Should show selected state via aria-selected await expect.element(option).toHaveAttribute("aria-selected", "true"); // Footer should show selected filename in a tag await expect.element(screen.getByRole("strong")).toBeInTheDocument(); }); it("double click selects and calls onSelect", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); // Use direct DOM dblclick to bypass inert overlay const btn = option.element().querySelector("button")!; btn.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); expect(onSelect).toHaveBeenCalledWith( expect.objectContaining({ id: "m1", filename: "photo.jpg" }), ); }); it("Insert button disabled when nothing selected", async () => { await renderModal(); // There are two Insert buttons — URL section and footer. // The footer Insert is the last one and should be disabled. await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); // The footer Insert (last one) should be disabled const lastInsert = insertBtns.at(-1); expect(lastInsert?.disabled).toBe(true); }); }); it("Insert button enabled when item selected, calls onSelect", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); // Select an item via direct DOM click const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); const itemBtn = option.element().querySelector("button")!; itemBtn.click(); // Wait for selection to register await expect.element(option).toHaveAttribute("aria-selected", "true"); // Click the footer Insert button (last Insert button) await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); const lastInsert = insertBtns.at(-1)!; expect(lastInsert.disabled).toBe(false); lastInsert.click(); }); expect(onSelect).toHaveBeenCalledWith( expect.objectContaining({ id: "m1", filename: "photo.jpg" }), ); }); }); describe("URL input", () => { it("invalid URL shows error", async () => { const screen = await renderModal(); // The URL input has aria-label "Image URL" const urlInput = screen.getByLabelText("Image URL"); await expect.element(urlInput).toBeInTheDocument(); // Type an invalid URL — use direct DOM since we're inside a dialog const inputEl = urlInput.element() as HTMLInputElement; // Manually set value and trigger change const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(inputEl, "not-a-url"); inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); // Click the URL Insert button (first Insert button) await vi.waitFor(() => { const urlInsert = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Insert", )!; expect(urlInsert.disabled).toBe(false); urlInsert.click(); }); await expect.element(screen.getByText("Please enter a valid URL")).toBeInTheDocument(); }); it("URL input: typing a URL and submitting triggers probe", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); const urlInput = screen.getByLabelText("Image URL"); const inputEl = urlInput.element() as HTMLInputElement; const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(inputEl, "https://example.com/test.jpg"); inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); // Click URL Insert button await vi.waitFor(() => { const urlInsert = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Insert", )!; urlInsert.click(); }); // Image probe will fail in test env, so either onSelect called or error shown await vi.waitFor( () => { const called = onSelect.mock.calls.length > 0; const hasError = document.body.textContent?.includes("Could not load image from URL") ?? false; expect(called || hasError).toBe(true); }, { timeout: 3000 }, ); }); it("hideUrlInput hides the URL input section (for non-image pickers)", async () => { const screen = await renderModal({ hideUrlInput: true }); // "Insert from URL" label should not appear when hidden await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); expect(document.body.textContent).not.toContain("Insert from URL"); expect(document.body.textContent).not.toContain("or choose from library"); // The URL input itself should not be in the DOM const urlInput = document.querySelector('input[aria-label="Image URL"]'); expect(urlInput).toBeNull(); }); it("localOnly hides the URL input section", async () => { // `localOnly` is for fields whose storage model only persists a local // mediaId (e.g. site `logo`, `favicon`, `seo.defaultOgImage`). Selecting // an external URL would return an item the server cannot resolve later. const screen = await renderModal({ localOnly: true }); await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); expect(document.body.textContent).not.toContain("Insert from URL"); const urlInput = document.querySelector('input[aria-label="Image URL"]'); expect(urlInput).toBeNull(); }); it("renders external provider tabs by default (control for localOnly)", async () => { // Establishes that providers DO appear without `localOnly`. Without // this control assertion, the suppression test below could pass // purely because the providers query hadn't resolved yet. const api = await import("../../src/lib/api"); (api.fetchMediaProviders as any).mockResolvedValueOnce([ { id: "cloudflare-images", name: "Cloudflare Images", capabilities: { upload: true, search: false }, }, ]); const screen = await renderModal(); await expect.element(screen.getByText("Cloudflare Images")).toBeInTheDocument(); }); it("localOnly suppresses external provider tabs and skips the providers fetch", async () => { const api = await import("../../src/lib/api"); (api.fetchMediaProviders as any).mockResolvedValueOnce([ { id: "cloudflare-images", name: "Cloudflare Images", capabilities: { upload: true, search: false }, }, { id: "unsplash", name: "Unsplash", capabilities: { upload: false, search: true }, }, ]); const screen = await renderModal({ localOnly: true }); await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); // External providers must not be reachable through any tab when // localOnly is set, even if the API would report them. expect(document.body.textContent).not.toContain("Cloudflare Images"); expect(document.body.textContent).not.toContain("Unsplash"); // `enabled: open && !localOnly` short-circuits the query, so the // fetch should never have been issued. This proves the assertion // above isn't just racing the resolve. expect(api.fetchMediaProviders).not.toHaveBeenCalled(); }); }); describe("mediaKind", () => { it("uses file-specific copy when mediaKind is 'file'", async () => { // Use an empty media list so the empty state copy renders. const api = await import("../../src/lib/api"); (api.fetchMediaList as any).mockResolvedValueOnce({ items: [] }); const screen = await renderModal({ mediaKind: "file", hideUrlInput: true }); // Default title should be "Select File", not "Select Image" await expect.element(screen.getByText("Select File")).toBeInTheDocument(); expect(document.body.textContent).not.toContain("Select Image"); // Empty-state hint and CTA should reference files, not images await expect.element(screen.getByText("Upload a file to get started")).toBeInTheDocument(); await expect.element(screen.getByText("Upload File")).toBeInTheDocument(); expect(document.body.textContent).not.toContain("Upload an image to get started"); expect(document.body.textContent).not.toContain("Upload Image"); }); it("defaults to image-specific copy when mediaKind is unset", async () => { const api = await import("../../src/lib/api"); (api.fetchMediaList as any).mockResolvedValueOnce({ items: [] }); const screen = await renderModal(); await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); await expect.element(screen.getByText("Upload an image to get started")).toBeInTheDocument(); }); }); describe("cancel and close", () => { it("Cancel closes modal", async () => { const onOpenChange = vi.fn(); const screen = await renderModal({ onOpenChange }); await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); // Direct DOM click to bypass inert overlay const cancelEl = screen.getByText("Cancel").element(); const cancelBtn = cancelEl.closest("button")!; cancelBtn.click(); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); describe("state reset", () => { it("state resets when modal reopens", async () => { const onSelect = vi.fn(); const onOpenChange = vi.fn(); const screen = await renderModal({ open: true, onSelect, onOpenChange }); // Select an item const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); const btn = option.element().querySelector("button")!; btn.click(); // Verify selection await expect.element(option).toHaveAttribute("aria-selected", "true"); // Close modal await screen.rerender( , ); // Reopen modal await screen.rerender( , ); // Footer Insert should be disabled (no selection after reset) await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); const lastInsert = insertBtns.at(-1); expect(lastInsert?.disabled).toBe(true); }); }); }); describe("upload", () => { it("upload button and file input are present", async () => { const screen = await renderModal(); await expect .element(screen.getByRole("button", { name: UPLOAD_BUTTON_REGEX })) .toBeInTheDocument(); await expect.element(screen.getByLabelText("Upload file")).toBeInTheDocument(); }); }); describe("load more pagination", () => { it("renders Load More button when first page returns nextCursor", async () => { const api = await import("../../src/lib/api"); (api.fetchMediaList as any).mockResolvedValueOnce({ items: [ { id: "p1", filename: "page1.jpg", mimeType: "image/jpeg", url: "/media/page1.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-01", }, ], nextCursor: "cursor-2", }); const screen = await renderModal(); await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Load More" })).toBeInTheDocument(); }); it("does not render Load More button when no nextCursor", async () => { const screen = await renderModal(); // Default mock returns 2 items with no nextCursor → button should be absent await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull(); }); it("keeps already-loaded items visible while fetching the next page", async () => { // Reproduces the Copilot review concern: when the next-page fetch is // in flight, the picker grid must not blank out into a centered // loader — the user's prior selection / scroll context would be lost. const api = await import("../../src/lib/api"); const mock = api.fetchMediaList as any; mock.mockReset(); let resolveSecond: (value: unknown) => void = () => {}; const secondPagePromise = new Promise((resolve) => { resolveSecond = resolve; }); mock .mockResolvedValueOnce({ items: [ { id: "p1", filename: "page1.jpg", mimeType: "image/jpeg", url: "/media/page1.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-01", }, ], nextCursor: "cursor-2", }) .mockReturnValueOnce(secondPagePromise); const screen = await renderModal(); await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument(); const loadMoreBtn = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Load More", )!; loadMoreBtn.click(); // While the second page is still pending, the first-page item must // stay in the DOM (not be replaced by a centered loader). await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument(); resolveSecond({ items: [] }); }); it("Load More click fetches the next page with the previous cursor", async () => { const api = await import("../../src/lib/api"); const mock = api.fetchMediaList as any; mock.mockReset(); mock .mockResolvedValueOnce({ items: [ { id: "p1", filename: "page1.jpg", mimeType: "image/jpeg", url: "/media/page1.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-01", }, ], nextCursor: "cursor-2", }) .mockResolvedValueOnce({ items: [ { id: "p2", filename: "page2.jpg", mimeType: "image/jpeg", url: "/media/page2.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-02", }, ], }); const screen = await renderModal(); await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument(); // Direct DOM click to bypass the dialog's inert overlay const loadMoreBtn = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Load More", )!; loadMoreBtn.click(); await expect.element(screen.getByRole("option", { name: "page2.jpg" })).toBeInTheDocument(); // Second call should have been made with the previous response's cursor. expect(mock).toHaveBeenCalledTimes(2); expect(mock.mock.calls[1][0]).toEqual( expect.objectContaining({ cursor: "cursor-2", limit: 100 }), ); }); }); });