b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
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 `<input aria-label="Upload 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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
}
|
|
|
|
function renderModal(props: Partial<React.ComponentProps<typeof MediaPickerModal>> = {}) {
|
|
const defaultProps: React.ComponentProps<typeof MediaPickerModal> = {
|
|
open: true,
|
|
onOpenChange: vi.fn(),
|
|
onSelect: vi.fn(),
|
|
...props,
|
|
};
|
|
return render(
|
|
<QueryWrapper>
|
|
<MediaPickerModal {...defaultProps} />
|
|
</QueryWrapper>,
|
|
);
|
|
}
|
|
|
|
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 <strong> 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(
|
|
<QueryWrapper>
|
|
<MediaPickerModal open={false} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
</QueryWrapper>,
|
|
);
|
|
|
|
// Reopen modal
|
|
await screen.rerender(
|
|
<QueryWrapper>
|
|
<MediaPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
</QueryWrapper>,
|
|
);
|
|
|
|
// 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 }),
|
|
);
|
|
});
|
|
});
|
|
});
|